diff --git a/Images.xcassets/Chat List/PeerPinnedIcon.imageset/Contents.json b/Images.xcassets/Chat List/PeerPinnedIcon.imageset/Contents.json new file mode 100644 index 0000000000..fa5437a93d --- /dev/null +++ b/Images.xcassets/Chat List/PeerPinnedIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_chatslistpin@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_chatslistpin@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/PeerPinnedIcon.imageset/ic_chatslistpin@2x.png b/Images.xcassets/Chat List/PeerPinnedIcon.imageset/ic_chatslistpin@2x.png new file mode 100644 index 0000000000..27b01a0aae Binary files /dev/null and b/Images.xcassets/Chat List/PeerPinnedIcon.imageset/ic_chatslistpin@2x.png differ diff --git a/Images.xcassets/Chat List/PeerPinnedIcon.imageset/ic_chatslistpin@3x.png b/Images.xcassets/Chat List/PeerPinnedIcon.imageset/ic_chatslistpin@3x.png new file mode 100644 index 0000000000..b5ff3bbb36 Binary files /dev/null and b/Images.xcassets/Chat List/PeerPinnedIcon.imageset/ic_chatslistpin@3x.png differ diff --git a/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/Contents.json b/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/Contents.json new file mode 100644 index 0000000000..a8f1aa9086 --- /dev/null +++ b/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_delete@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_delete@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/ic_delete@2x.png b/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/ic_delete@2x.png new file mode 100644 index 0000000000..1ef4b30be9 Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/ic_delete@2x.png differ diff --git a/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/ic_delete@3x.png b/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/ic_delete@3x.png new file mode 100644 index 0000000000..7f54eaed52 Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionDeleteIcon.imageset/ic_delete@3x.png differ diff --git a/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/Contents.json b/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/Contents.json new file mode 100644 index 0000000000..098b874b4e --- /dev/null +++ b/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_unmute@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_unmute@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/ic_unmute@2x.png b/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/ic_unmute@2x.png new file mode 100644 index 0000000000..05598ef8fb Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/ic_unmute@2x.png differ diff --git a/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/ic_unmute@3x.png b/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/ic_unmute@3x.png new file mode 100644 index 0000000000..85ea2858fb Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionMuteIcon.imageset/ic_unmute@3x.png differ diff --git a/Images.xcassets/Chat List/RevealActionPinIcon.imageset/Contents.json b/Images.xcassets/Chat List/RevealActionPinIcon.imageset/Contents.json new file mode 100644 index 0000000000..09455f35d1 --- /dev/null +++ b/Images.xcassets/Chat List/RevealActionPinIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_pin@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_pin@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/RevealActionPinIcon.imageset/ic_pin@2x.png b/Images.xcassets/Chat List/RevealActionPinIcon.imageset/ic_pin@2x.png new file mode 100644 index 0000000000..f250acc2ca Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionPinIcon.imageset/ic_pin@2x.png differ diff --git a/Images.xcassets/Chat List/RevealActionPinIcon.imageset/ic_pin@3x.png b/Images.xcassets/Chat List/RevealActionPinIcon.imageset/ic_pin@3x.png new file mode 100644 index 0000000000..72683b3fd2 Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionPinIcon.imageset/ic_pin@3x.png differ diff --git a/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/Contents.json b/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/Contents.json new file mode 100644 index 0000000000..098b874b4e --- /dev/null +++ b/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_unmute@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_unmute@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/ic_unmute@2x.png b/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/ic_unmute@2x.png new file mode 100644 index 0000000000..40e2ac3691 Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/ic_unmute@2x.png differ diff --git a/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/ic_unmute@3x.png b/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/ic_unmute@3x.png new file mode 100644 index 0000000000..cb83255814 Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionUnmuteIcon.imageset/ic_unmute@3x.png differ diff --git a/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/Contents.json b/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/Contents.json new file mode 100644 index 0000000000..1a7c04bc8d --- /dev/null +++ b/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_unpin@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_unpin@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/ic_unpin@2x.png b/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/ic_unpin@2x.png new file mode 100644 index 0000000000..813edc9f5d Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/ic_unpin@2x.png differ diff --git a/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/ic_unpin@3x.png b/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/ic_unpin@3x.png new file mode 100644 index 0000000000..0cd3e4bb5d Binary files /dev/null and b/Images.xcassets/Chat List/RevealActionUnpinIcon.imageset/ic_unpin@3x.png differ diff --git a/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/Contents.json b/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/Contents.json new file mode 100644 index 0000000000..9fb5e59f92 --- /dev/null +++ b/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernConversationSecretAccessoryTimer@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ModernConversationSecretAccessoryTimer@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/ModernConversationSecretAccessoryTimer@2x.png b/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/ModernConversationSecretAccessoryTimer@2x.png new file mode 100644 index 0000000000..e0ff0e6e63 Binary files /dev/null and b/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/ModernConversationSecretAccessoryTimer@2x.png differ diff --git a/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/ModernConversationSecretAccessoryTimer@3x.png b/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/ModernConversationSecretAccessoryTimer@3x.png new file mode 100644 index 0000000000..3e47b2b27b Binary files /dev/null and b/Images.xcassets/Chat/Input/Text/AccessoryIconTimer.imageset/ModernConversationSecretAccessoryTimer@3x.png differ diff --git a/Images.xcassets/Chat/Message/SecretMediaIcon.imageset/Contents.json b/Images.xcassets/Chat/Message/SecretMediaIcon.imageset/Contents.json new file mode 100644 index 0000000000..868d3a72aa --- /dev/null +++ b/Images.xcassets/Chat/Message/SecretMediaIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "SecretPhotoFire@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/Message/SecretMediaIcon.imageset/SecretPhotoFire@2x.png b/Images.xcassets/Chat/Message/SecretMediaIcon.imageset/SecretPhotoFire@2x.png new file mode 100644 index 0000000000..803133c351 Binary files /dev/null and b/Images.xcassets/Chat/Message/SecretMediaIcon.imageset/SecretPhotoFire@2x.png differ diff --git a/Images.xcassets/Contact List/Contents.json b/Images.xcassets/Contact List/Contents.json new file mode 100644 index 0000000000..38f0c81fc2 --- /dev/null +++ b/Images.xcassets/Contact List/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "provides-namespace" : true + } +} \ No newline at end of file diff --git a/Images.xcassets/Contact List/CreateChannelActionIcon.imageset/Contents.json b/Images.xcassets/Contact List/CreateChannelActionIcon.imageset/Contents.json new file mode 100644 index 0000000000..160d2fbd89 --- /dev/null +++ b/Images.xcassets/Contact List/CreateChannelActionIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernContactListBroadcastIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Contact List/CreateChannelActionIcon.imageset/ModernContactListBroadcastIcon@2x.png b/Images.xcassets/Contact List/CreateChannelActionIcon.imageset/ModernContactListBroadcastIcon@2x.png new file mode 100644 index 0000000000..4069a53db0 Binary files /dev/null and b/Images.xcassets/Contact List/CreateChannelActionIcon.imageset/ModernContactListBroadcastIcon@2x.png differ diff --git a/Images.xcassets/Contact List/CreateGroupActionIcon.imageset/Contents.json b/Images.xcassets/Contact List/CreateGroupActionIcon.imageset/Contents.json new file mode 100644 index 0000000000..b296d80fa6 --- /dev/null +++ b/Images.xcassets/Contact List/CreateGroupActionIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernContactListCreateGroupIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Contact List/CreateGroupActionIcon.imageset/ModernContactListCreateGroupIcon@2x.png b/Images.xcassets/Contact List/CreateGroupActionIcon.imageset/ModernContactListCreateGroupIcon@2x.png new file mode 100644 index 0000000000..91c4f9716a Binary files /dev/null and b/Images.xcassets/Contact List/CreateGroupActionIcon.imageset/ModernContactListCreateGroupIcon@2x.png differ diff --git a/Images.xcassets/Contact List/CreateSecretChatActionIcon.imageset/Contents.json b/Images.xcassets/Contact List/CreateSecretChatActionIcon.imageset/Contents.json new file mode 100644 index 0000000000..dfe0e10466 --- /dev/null +++ b/Images.xcassets/Contact List/CreateSecretChatActionIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernContactListCreateSecretChatIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Contact List/CreateSecretChatActionIcon.imageset/ModernContactListCreateSecretChatIcon@2x.png b/Images.xcassets/Contact List/CreateSecretChatActionIcon.imageset/ModernContactListCreateSecretChatIcon@2x.png new file mode 100644 index 0000000000..0d340dfafd Binary files /dev/null and b/Images.xcassets/Contact List/CreateSecretChatActionIcon.imageset/ModernContactListCreateSecretChatIcon@2x.png differ diff --git a/Images.xcassets/Contact List/SelectionChecked.imageset/Contents.json b/Images.xcassets/Contact List/SelectionChecked.imageset/Contents.json new file mode 100644 index 0000000000..7ef72610d4 --- /dev/null +++ b/Images.xcassets/Contact List/SelectionChecked.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernContactSelectionChecked@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Contact List/SelectionChecked.imageset/ModernContactSelectionChecked@2x.png b/Images.xcassets/Contact List/SelectionChecked.imageset/ModernContactSelectionChecked@2x.png new file mode 100644 index 0000000000..ac65634089 Binary files /dev/null and b/Images.xcassets/Contact List/SelectionChecked.imageset/ModernContactSelectionChecked@2x.png differ diff --git a/Images.xcassets/Contact List/SelectionUnchecked.imageset/Contents.json b/Images.xcassets/Contact List/SelectionUnchecked.imageset/Contents.json new file mode 100644 index 0000000000..0b02864895 --- /dev/null +++ b/Images.xcassets/Contact List/SelectionUnchecked.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernContactSelectionEmpty@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Contact List/SelectionUnchecked.imageset/ModernContactSelectionEmpty@2x.png b/Images.xcassets/Contact List/SelectionUnchecked.imageset/ModernContactSelectionEmpty@2x.png new file mode 100644 index 0000000000..9f41ee42bf Binary files /dev/null and b/Images.xcassets/Contact List/SelectionUnchecked.imageset/ModernContactSelectionEmpty@2x.png differ diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index ac07937ffd..4e5f09d723 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -9,9 +9,20 @@ /* 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 */; }; - D003702E1DA43052004308D3 /* PeerInfoAvatarAndNameItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D003702D1DA43052004308D3 /* PeerInfoAvatarAndNameItem.swift */; }; - D00370301DA43077004308D3 /* PeerInfoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D003702F1DA43077004308D3 /* PeerInfoItem.swift */; }; - D00370321DA46C06004308D3 /* PeerInfoTextWithLabelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00370311DA46C06004308D3 /* PeerInfoTextWithLabelItem.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 */; }; + D00B3F9E1E3A4847003872C3 /* ItemListSectionHeaderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00B3F9D1E3A4847003872C3 /* ItemListSectionHeaderItem.swift */; }; + D00B3FA01E3A76D4003872C3 /* ItemListSwitchItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00B3F9F1E3A76D4003872C3 /* ItemListSwitchItem.swift */; }; + D00B3FA21E3A983E003872C3 /* ItemListTextItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00B3FA11E3A983E003872C3 /* ItemListTextItem.swift */; }; + D00C7CD71E3664070080C3D5 /* ItemListMultilineInputItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CD61E3664070080C3D5 /* ItemListMultilineInputItem.swift */; }; + D00C7CD91E36B2DB0080C3D5 /* ContactListNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CD81E36B2DB0080C3D5 /* ContactListNode.swift */; }; + D00C7CDC1E3776E50080C3D5 /* SecretMediaPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CDB1E3776E50080C3D5 /* SecretMediaPreviewController.swift */; }; + D00C7CDE1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CDD1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift */; }; + D00C7CE61E378FD00080C3D5 /* RadialTimeoutNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CE51E378FD00080C3D5 /* RadialTimeoutNode.swift */; }; + D00C7CE91E379B820080C3D5 /* ChatSecretAutoremoveTimerActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CE81E379B820080C3D5 /* ChatSecretAutoremoveTimerActionSheet.swift */; }; + D00C7CF71E37BF680080C3D5 /* SecretChatKeyVisualization.h in Headers */ = {isa = PBXBuildFile; fileRef = D00C7CF51E37BF680080C3D5 /* SecretChatKeyVisualization.h */; }; + D00C7CF81E37BF680080C3D5 /* SecretChatKeyVisualization.m in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CF61E37BF680080C3D5 /* SecretChatKeyVisualization.m */; }; D00E15261DDBD4E700ACF65C /* LegacyCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00E15251DDBD4E700ACF65C /* LegacyCamera.swift */; }; D0105D5A1D80B957008755D8 /* ChatChannelSubscriberInputPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0105D591D80B957008755D8 /* ChatChannelSubscriberInputPanelNode.swift */; }; D017494E1E1059570057C89A /* StringWithAppliedEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D017494D1E1059570057C89A /* StringWithAppliedEntities.swift */; }; @@ -25,6 +36,12 @@ D0177B841DFB095000A5083A /* FileMediaResourceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0177B831DFB095000A5083A /* FileMediaResourceStatus.swift */; }; D01AC9181DD5033100E8160F /* ChatMessageActionButtonsNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01AC9171DD5033100E8160F /* ChatMessageActionButtonsNode.swift */; }; D01AC91F1DD5E09000E8160F /* EditAccessoryPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01AC91E1DD5E09000E8160F /* EditAccessoryPanelNode.swift */; }; + D01B27951E38F3BF0022A4C0 /* ItemListControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B27941E38F3BF0022A4C0 /* ItemListControllerNode.swift */; }; + D01B27991E39144C0022A4C0 /* ItemListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B27981E39144C0022A4C0 /* ItemListController.swift */; }; + D01B279B1E39386C0022A4C0 /* SettingsControllerEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B279A1E39386C0022A4C0 /* SettingsControllerEntries.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 */; }; D01F66131DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F66121DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift */; }; D0215D381E040F53001A0B1E /* InstantPageNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0215D371E040F53001A0B1E /* InstantPageNode.swift */; }; D0215D3A1E041003001A0B1E /* InstantPageLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0215D391E041003001A0B1E /* InstantPageLayout.swift */; }; @@ -44,6 +61,8 @@ D0215D561E043020001A0B1E /* InstantPageControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0215D551E043020001A0B1E /* InstantPageControllerNode.swift */; }; D0215D581E04302E001A0B1E /* InstantPageTileNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0215D571E04302E001A0B1E /* InstantPageTileNode.swift */; }; D0215D5A1E04310C001A0B1E /* InstantPageTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0215D591E04310C001A0B1E /* InstantPageTile.swift */; }; + D021E0A91E3AACA200AF709C /* ItemListEditableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0A81E3AACA200AF709C /* ItemListEditableItem.swift */; }; + D021E0AB1E3B9E2700AF709C /* ItemListRevealOptionsNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0AA1E3B9E2700AF709C /* ItemListRevealOptionsNode.swift */; }; D021E0CE1DB4135500C6B04F /* ChatMediaInputNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0CD1DB4135500C6B04F /* ChatMediaInputNode.swift */; }; D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0CF1DB413BC00C6B04F /* ChatInputNode.swift */; }; D021E0D21DB4147500C6B04F /* ChatInterfaceInputNodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0D11DB4147500C6B04F /* ChatInterfaceInputNodes.swift */; }; @@ -63,7 +82,7 @@ D02958021D6F0D5F00360E5E /* TapLongTapOrDoubleTapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02958011D6F0D5F00360E5E /* TapLongTapOrDoubleTapGestureRecognizer.swift */; }; D02BE0711D91814C000889C2 /* ChatHistoryGridNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02BE0701D91814C000889C2 /* ChatHistoryGridNode.swift */; }; D02BE0771D9190EF000889C2 /* GridMessageItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02BE0761D9190EF000889C2 /* GridMessageItem.swift */; }; - D03120F61DA534C1006A2A60 /* PeerInfoActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03120F51DA534C1006A2A60 /* PeerInfoActionItem.swift */; }; + D03120F61DA534C1006A2A60 /* ItemListActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03120F51DA534C1006A2A60 /* ItemListActionItem.swift */; }; D03922A71DF70E3F000F2CE9 /* MediaPlayerScrubbingNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03922A61DF70E3F000F2CE9 /* MediaPlayerScrubbingNode.swift */; }; D039EB031DEAEFEE00886EBC /* ChatTextInputAudioRecordingOverlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D039EB021DEAEFEE00886EBC /* ChatTextInputAudioRecordingOverlayButton.swift */; }; D039EB081DEC725600886EBC /* ChatTextInputAudioRecordingTimeNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D039EB071DEC725600886EBC /* ChatTextInputAudioRecordingTimeNode.swift */; }; @@ -104,6 +123,17 @@ D07CFF7D1DCA273400761F81 /* ChatListViewTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07CFF7C1DCA273400761F81 /* ChatListViewTransition.swift */; }; D07CFF7F1DCA308500761F81 /* ChatListNodeLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07CFF7E1DCA308500761F81 /* ChatListNodeLocation.swift */; }; D07CFF871DCAAE5E00761F81 /* ForwardAccessoryPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07CFF861DCAAE5E00761F81 /* ForwardAccessoryPanelNode.swift */; }; + D08774F81E3DE7BF00A97350 /* ItemListEditableDeleteControlNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08774F71E3DE7BF00A97350 /* ItemListEditableDeleteControlNode.swift */; }; + D08774FA1E3E2A5600A97350 /* ItemListCheckboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08774F91E3E2A5600A97350 /* ItemListCheckboxItem.swift */; }; + D08775091E3E59DE00A97350 /* PeerNotificationSoundStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08775081E3E59DE00A97350 /* PeerNotificationSoundStrings.swift */; }; + D087750C1E3E7B7600A97350 /* PreferencesKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087750B1E3E7B7600A97350 /* PreferencesKeys.swift */; }; + D08775101E3F46A400A97350 /* ComposeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087750F1E3F46A400A97350 /* ComposeController.swift */; }; + D08775121E3F46AB00A97350 /* ComposeControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08775111E3F46AB00A97350 /* ComposeControllerNode.swift */; }; + D08775141E3F4A7700A97350 /* ContactListNameIndexHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08775131E3F4A7700A97350 /* ContactListNameIndexHeader.swift */; }; + D08775191E3F53FC00A97350 /* ContactMultiselectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08775181E3F53FC00A97350 /* ContactMultiselectionController.swift */; }; + D087751C1E3F542500A97350 /* ContactMultiselectionControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087751B1E3F542500A97350 /* ContactMultiselectionControllerNode.swift */; }; + D087751E1E3F579300A97350 /* CounterContollerTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087751D1E3F579300A97350 /* CounterContollerTitleView.swift */; }; + D08775201E3F595000A97350 /* ContactListActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D087751F1E3F595000A97350 /* ContactListActionItem.swift */; }; D08C367F1DB66A820064C744 /* ChatMediaInputPanelEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08C367E1DB66A820064C744 /* ChatMediaInputPanelEntries.swift */; }; D08C36811DB66AAC0064C744 /* ChatMediaInputGridEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08C36801DB66AAC0064C744 /* ChatMediaInputGridEntries.swift */; }; D08C36831DB66AD40064C744 /* ChatMediaInputStickerGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08C36821DB66AD40064C744 /* ChatMediaInputStickerGridItem.swift */; }; @@ -117,6 +147,7 @@ D099EA291DE76655001AF5A8 /* ManagedVideoNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D099EA281DE76655001AF5A8 /* ManagedVideoNode.swift */; }; D099EA2D1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift in Sources */ = {isa = PBXBuildFile; fileRef = D099EA2C1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift */; }; D099EA2F1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift in Sources */ = {isa = PBXBuildFile; fileRef = D099EA2E1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift */; }; + D0A749971E3AA25200AD786E /* NotificationSoundSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A749961E3AA25200AD786E /* NotificationSoundSelection.swift */; }; D0AB0BB11D6718DA002C78E7 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D0AB0BB01D6718DA002C78E7 /* libiconv.tbd */; }; D0AB0BB31D6718EB002C78E7 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D0AB0BB21D6718EB002C78E7 /* libz.tbd */; }; D0AB0BB51D6718F1002C78E7 /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0AB0BB41D6718F1002C78E7 /* CoreMedia.framework */; }; @@ -124,19 +155,21 @@ D0B417C31D7DE54E004562A4 /* ChatPresentationInterfaceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B417C21D7DE54E004562A4 /* ChatPresentationInterfaceState.swift */; }; D0B7F8E21D8A18070045D939 /* PeerMediaCollectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B7F8E11D8A18070045D939 /* PeerMediaCollectionController.swift */; }; D0B7F8E81D8A1F5F0045D939 /* PeerMediaCollectionControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B7F8E71D8A1F5F0045D939 /* PeerMediaCollectionControllerNode.swift */; }; - D0B843921DA7F13E005F29E1 /* PeerInfoDisclosureItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843911DA7F13E005F29E1 /* PeerInfoDisclosureItem.swift */; }; + D0B843921DA7F13E005F29E1 /* ItemListDisclosureItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843911DA7F13E005F29E1 /* ItemListDisclosureItem.swift */; }; D0B843CD1DA903BB005F29E1 /* PeerInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843CC1DA903BB005F29E1 /* PeerInfoController.swift */; }; D0B843CF1DA922AD005F29E1 /* PeerInfoEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843CE1DA922AD005F29E1 /* PeerInfoEntries.swift */; }; D0B843D11DA922D7005F29E1 /* UserInfoEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */; }; D0B843D31DA922E3005F29E1 /* ChannelInfoEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D21DA922E3005F29E1 /* ChannelInfoEntries.swift */; }; D0B843D51DA95427005F29E1 /* GroupInfoEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D41DA95427005F29E1 /* GroupInfoEntries.swift */; }; - D0B843D91DAAAA0C005F29E1 /* PeerInfoPeerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D81DAAAA0C005F29E1 /* PeerInfoPeerItem.swift */; }; - D0B843DB1DAAB138005F29E1 /* PeerInfoPeerActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843DA1DAAB138005F29E1 /* PeerInfoPeerActionItem.swift */; }; + D0B843D91DAAAA0C005F29E1 /* ItemListPeerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D81DAAAA0C005F29E1 /* ItemListPeerItem.swift */; }; + D0B843DB1DAAB138005F29E1 /* ItemListPeerActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843DA1DAAB138005F29E1 /* ItemListPeerActionItem.swift */; }; D0B844561DAC3AEE005F29E1 /* PresenceStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B844551DAC3AEE005F29E1 /* PresenceStrings.swift */; }; D0B844581DAC44E8005F29E1 /* PeerPresenceStatusManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B844571DAC44E8005F29E1 /* PeerPresenceStatusManager.swift */; }; D0BA6F831D784C520034826E /* ChatInputPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BA6F821D784C520034826E /* ChatInputPanelNode.swift */; }; D0BA6F851D784ECD0034826E /* ChatInterfaceStateInputPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BA6F841D784ECD0034826E /* ChatInterfaceStateInputPanels.swift */; }; D0BA6F881D784F880034826E /* ChatMessageSelectionInputPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BA6F871D784F880034826E /* ChatMessageSelectionInputPanelNode.swift */; }; + D0BC38631E3F9EFA0044D6FE /* EditableTokenListNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BC38621E3F9EFA0044D6FE /* EditableTokenListNode.swift */; }; + D0BC386A1E3FB94D0044D6FE /* CreateGroupController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BC38691E3FB94D0044D6FE /* CreateGroupController.swift */; }; D0C932361E0988C60074F044 /* ChatButtonKeyboardInputNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C932351E0988C60074F044 /* ChatButtonKeyboardInputNode.swift */; }; D0C932381E09E0EA0074F044 /* ChatBotInfoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C932371E09E0EA0074F044 /* ChatBotInfoItem.swift */; }; D0C9323C1E0B4AE90074F044 /* DataAndStorageSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C9323B1E0B4AE90074F044 /* DataAndStorageSettingsController.swift */; }; @@ -353,9 +386,20 @@ /* 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 = ""; }; - D003702D1DA43052004308D3 /* PeerInfoAvatarAndNameItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoAvatarAndNameItem.swift; sourceTree = ""; }; - D003702F1DA43077004308D3 /* PeerInfoItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoItem.swift; sourceTree = ""; }; - D00370311DA46C06004308D3 /* PeerInfoTextWithLabelItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoTextWithLabelItem.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 = ""; }; + D00B3F9D1E3A4847003872C3 /* ItemListSectionHeaderItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListSectionHeaderItem.swift; sourceTree = ""; }; + D00B3F9F1E3A76D4003872C3 /* ItemListSwitchItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListSwitchItem.swift; sourceTree = ""; }; + D00B3FA11E3A983E003872C3 /* ItemListTextItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListTextItem.swift; sourceTree = ""; }; + D00C7CD61E3664070080C3D5 /* ItemListMultilineInputItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListMultilineInputItem.swift; sourceTree = ""; }; + D00C7CD81E36B2DB0080C3D5 /* ContactListNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactListNode.swift; sourceTree = ""; }; + D00C7CDB1E3776E50080C3D5 /* SecretMediaPreviewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretMediaPreviewController.swift; sourceTree = ""; }; + D00C7CDD1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretMediaPreviewControllerNode.swift; sourceTree = ""; }; + D00C7CE51E378FD00080C3D5 /* RadialTimeoutNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadialTimeoutNode.swift; sourceTree = ""; }; + D00C7CE81E379B820080C3D5 /* ChatSecretAutoremoveTimerActionSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatSecretAutoremoveTimerActionSheet.swift; sourceTree = ""; }; + D00C7CF51E37BF680080C3D5 /* SecretChatKeyVisualization.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SecretChatKeyVisualization.h; sourceTree = ""; }; + D00C7CF61E37BF680080C3D5 /* SecretChatKeyVisualization.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SecretChatKeyVisualization.m; 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 = ""; }; D017494D1E1059570057C89A /* StringWithAppliedEntities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringWithAppliedEntities.swift; sourceTree = ""; }; @@ -369,6 +413,12 @@ D0177B831DFB095000A5083A /* FileMediaResourceStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileMediaResourceStatus.swift; sourceTree = ""; }; D01AC9171DD5033100E8160F /* ChatMessageActionButtonsNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageActionButtonsNode.swift; sourceTree = ""; }; D01AC91E1DD5E09000E8160F /* EditAccessoryPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditAccessoryPanelNode.swift; sourceTree = ""; }; + D01B27941E38F3BF0022A4C0 /* ItemListControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListControllerNode.swift; sourceTree = ""; }; + D01B27981E39144C0022A4C0 /* ItemListController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListController.swift; sourceTree = ""; }; + D01B279A1E39386C0022A4C0 /* SettingsControllerEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsControllerEntries.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 = ""; }; D01F66121DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTextInputAudioRecordingButton.swift; sourceTree = ""; }; D0215D371E040F53001A0B1E /* InstantPageNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstantPageNode.swift; sourceTree = ""; }; D0215D391E041003001A0B1E /* InstantPageLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstantPageLayout.swift; sourceTree = ""; }; @@ -388,6 +438,8 @@ D0215D551E043020001A0B1E /* InstantPageControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstantPageControllerNode.swift; sourceTree = ""; }; D0215D571E04302E001A0B1E /* InstantPageTileNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstantPageTileNode.swift; sourceTree = ""; }; D0215D591E04310C001A0B1E /* InstantPageTile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstantPageTile.swift; sourceTree = ""; }; + D021E0A81E3AACA200AF709C /* ItemListEditableItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListEditableItem.swift; sourceTree = ""; }; + D021E0AA1E3B9E2700AF709C /* ItemListRevealOptionsNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListRevealOptionsNode.swift; sourceTree = ""; }; D021E0CD1DB4135500C6B04F /* ChatMediaInputNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputNode.swift; sourceTree = ""; }; 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 = ""; }; @@ -407,7 +459,7 @@ D02958011D6F0D5F00360E5E /* TapLongTapOrDoubleTapGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TapLongTapOrDoubleTapGestureRecognizer.swift; sourceTree = ""; }; D02BE0701D91814C000889C2 /* ChatHistoryGridNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatHistoryGridNode.swift; sourceTree = ""; }; D02BE0761D9190EF000889C2 /* GridMessageItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridMessageItem.swift; sourceTree = ""; }; - D03120F51DA534C1006A2A60 /* PeerInfoActionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoActionItem.swift; sourceTree = ""; }; + D03120F51DA534C1006A2A60 /* ItemListActionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListActionItem.swift; sourceTree = ""; }; D03922A61DF70E3F000F2CE9 /* MediaPlayerScrubbingNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaPlayerScrubbingNode.swift; sourceTree = ""; }; D039EB021DEAEFEE00886EBC /* ChatTextInputAudioRecordingOverlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTextInputAudioRecordingOverlayButton.swift; sourceTree = ""; }; D039EB071DEC725600886EBC /* ChatTextInputAudioRecordingTimeNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTextInputAudioRecordingTimeNode.swift; sourceTree = ""; }; @@ -448,6 +500,17 @@ D07CFF7C1DCA273400761F81 /* ChatListViewTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatListViewTransition.swift; sourceTree = ""; }; D07CFF7E1DCA308500761F81 /* ChatListNodeLocation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatListNodeLocation.swift; sourceTree = ""; }; D07CFF861DCAAE5E00761F81 /* ForwardAccessoryPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForwardAccessoryPanelNode.swift; sourceTree = ""; }; + D08774F71E3DE7BF00A97350 /* ItemListEditableDeleteControlNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListEditableDeleteControlNode.swift; sourceTree = ""; }; + D08774F91E3E2A5600A97350 /* ItemListCheckboxItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListCheckboxItem.swift; sourceTree = ""; }; + D08775081E3E59DE00A97350 /* PeerNotificationSoundStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerNotificationSoundStrings.swift; sourceTree = ""; }; + D087750B1E3E7B7600A97350 /* PreferencesKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesKeys.swift; sourceTree = ""; }; + D087750F1E3F46A400A97350 /* ComposeController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposeController.swift; sourceTree = ""; }; + D08775111E3F46AB00A97350 /* ComposeControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposeControllerNode.swift; sourceTree = ""; }; + D08775131E3F4A7700A97350 /* ContactListNameIndexHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactListNameIndexHeader.swift; sourceTree = ""; }; + D08775181E3F53FC00A97350 /* ContactMultiselectionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactMultiselectionController.swift; sourceTree = ""; }; + D087751B1E3F542500A97350 /* ContactMultiselectionControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactMultiselectionControllerNode.swift; sourceTree = ""; }; + D087751D1E3F579300A97350 /* CounterContollerTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CounterContollerTitleView.swift; sourceTree = ""; }; + D087751F1E3F595000A97350 /* ContactListActionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactListActionItem.swift; sourceTree = ""; }; D08C367E1DB66A820064C744 /* ChatMediaInputPanelEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputPanelEntries.swift; sourceTree = ""; }; D08C36801DB66AAC0064C744 /* ChatMediaInputGridEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputGridEntries.swift; sourceTree = ""; }; D08C36821DB66AD40064C744 /* ChatMediaInputStickerGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputStickerGridItem.swift; sourceTree = ""; }; @@ -462,6 +525,7 @@ D099EA281DE76655001AF5A8 /* ManagedVideoNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManagedVideoNode.swift; sourceTree = ""; }; D099EA2C1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerMessageManagedMediaId.swift; sourceTree = ""; }; D099EA2E1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatContextResultManagedMediaId.swift; sourceTree = ""; }; + D0A749961E3AA25200AD786E /* NotificationSoundSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSoundSelection.swift; sourceTree = ""; }; D0AB0BB01D6718DA002C78E7 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = usr/lib/libiconv.tbd; sourceTree = SDKROOT; }; D0AB0BB21D6718EB002C78E7 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; D0AB0BB41D6718F1002C78E7 /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; @@ -471,19 +535,21 @@ D0B417C21D7DE54E004562A4 /* ChatPresentationInterfaceState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatPresentationInterfaceState.swift; sourceTree = ""; }; D0B7F8E11D8A18070045D939 /* PeerMediaCollectionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerMediaCollectionController.swift; sourceTree = ""; }; D0B7F8E71D8A1F5F0045D939 /* PeerMediaCollectionControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerMediaCollectionControllerNode.swift; sourceTree = ""; }; - D0B843911DA7F13E005F29E1 /* PeerInfoDisclosureItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoDisclosureItem.swift; sourceTree = ""; }; + D0B843911DA7F13E005F29E1 /* ItemListDisclosureItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListDisclosureItem.swift; sourceTree = ""; }; D0B843CC1DA903BB005F29E1 /* PeerInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoController.swift; sourceTree = ""; }; D0B843CE1DA922AD005F29E1 /* PeerInfoEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoEntries.swift; sourceTree = ""; }; D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInfoEntries.swift; sourceTree = ""; }; D0B843D21DA922E3005F29E1 /* ChannelInfoEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelInfoEntries.swift; sourceTree = ""; }; D0B843D41DA95427005F29E1 /* GroupInfoEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupInfoEntries.swift; sourceTree = ""; }; - D0B843D81DAAAA0C005F29E1 /* PeerInfoPeerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoPeerItem.swift; sourceTree = ""; }; - D0B843DA1DAAB138005F29E1 /* PeerInfoPeerActionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoPeerActionItem.swift; sourceTree = ""; }; + D0B843D81DAAAA0C005F29E1 /* ItemListPeerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListPeerItem.swift; sourceTree = ""; }; + D0B843DA1DAAB138005F29E1 /* ItemListPeerActionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListPeerActionItem.swift; sourceTree = ""; }; D0B844551DAC3AEE005F29E1 /* PresenceStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceStrings.swift; sourceTree = ""; }; D0B844571DAC44E8005F29E1 /* PeerPresenceStatusManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerPresenceStatusManager.swift; sourceTree = ""; }; D0BA6F821D784C520034826E /* ChatInputPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInputPanelNode.swift; sourceTree = ""; }; D0BA6F841D784ECD0034826E /* ChatInterfaceStateInputPanels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceStateInputPanels.swift; sourceTree = ""; }; D0BA6F871D784F880034826E /* ChatMessageSelectionInputPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageSelectionInputPanelNode.swift; sourceTree = ""; }; + D0BC38621E3F9EFA0044D6FE /* EditableTokenListNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditableTokenListNode.swift; sourceTree = ""; }; + D0BC38691E3FB94D0044D6FE /* CreateGroupController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateGroupController.swift; sourceTree = ""; }; D0C932351E0988C60074F044 /* ChatButtonKeyboardInputNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatButtonKeyboardInputNode.swift; sourceTree = ""; }; D0C932371E09E0EA0074F044 /* ChatBotInfoItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatBotInfoItem.swift; sourceTree = ""; }; D0C9323B1E0B4AE90074F044 /* DataAndStorageSettingsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataAndStorageSettingsController.swift; sourceTree = ""; }; @@ -729,17 +795,19 @@ D003702C1DA43006004308D3 /* Components */ = { isa = PBXGroup; children = ( - D003702F1DA43077004308D3 /* PeerInfoItem.swift */, - D003702D1DA43052004308D3 /* PeerInfoAvatarAndNameItem.swift */, - D00370311DA46C06004308D3 /* PeerInfoTextWithLabelItem.swift */, - D03120F51DA534C1006A2A60 /* PeerInfoActionItem.swift */, - D0B843911DA7F13E005F29E1 /* PeerInfoDisclosureItem.swift */, - D0B843D81DAAAA0C005F29E1 /* PeerInfoPeerItem.swift */, - D0B843DA1DAAB138005F29E1 /* PeerInfoPeerActionItem.swift */, ); name = Components; sourceTree = ""; }; + D00C7CDA1E3776CA0080C3D5 /* Secret Preview */ = { + isa = PBXGroup; + children = ( + D00C7CDB1E3776E50080C3D5 /* SecretMediaPreviewController.swift */, + D00C7CDD1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift */, + ); + name = "Secret Preview"; + sourceTree = ""; + }; D017494F1E1067C00057C89A /* Hashtag Search */ = { isa = PBXGroup; children = ( @@ -749,6 +817,16 @@ name = "Hashtag Search"; sourceTree = ""; }; + D01B27931E38F3920022A4C0 /* Item List */ = { + isa = PBXGroup; + children = ( + D0E6521D1E3A2305004EEA91 /* Items */, + D01B27981E39144C0022A4C0 /* ItemListController.swift */, + D01B27941E38F3BF0022A4C0 /* ItemListControllerNode.swift */, + ); + name = "Item List"; + sourceTree = ""; + }; D021E0CC1DB4132E00C6B04F /* Input Nodes */ = { isa = PBXGroup; children = ( @@ -889,6 +967,47 @@ name = "Chat List Node"; sourceTree = ""; }; + D087750A1E3E7A6D00A97350 /* Settings */ = { + isa = PBXGroup; + children = ( + D01B279E1E394BD70022A4C0 /* InAppNotificationSettings.swift */, + D01B27A31E394FC90022A4C0 /* SecuritySettings.swift */, + ); + name = Settings; + sourceTree = ""; + }; + D087750D1E3F214200A97350 /* Contact List Node */ = { + isa = PBXGroup; + children = ( + D00C7CD81E36B2DB0080C3D5 /* ContactListNode.swift */, + D087751F1E3F595000A97350 /* ContactListActionItem.swift */, + D0F69E6F1D6B8C340046BCD6 /* ContactsPeerItem.swift */, + D0F69E721D6B8C340046BCD6 /* ContactsVCardItem.swift */, + D0F69E711D6B8C340046BCD6 /* ContactsSectionHeaderAccessoryItem.swift */, + D08775131E3F4A7700A97350 /* ContactListNameIndexHeader.swift */, + ); + name = "Contact List Node"; + sourceTree = ""; + }; + D087750E1E3F469700A97350 /* Compose */ = { + isa = PBXGroup; + children = ( + D087750F1E3F46A400A97350 /* ComposeController.swift */, + D08775111E3F46AB00A97350 /* ComposeControllerNode.swift */, + ); + name = Compose; + sourceTree = ""; + }; + D087751A1E3F540900A97350 /* Contact Multiselection */ = { + isa = PBXGroup; + children = ( + D087751D1E3F579300A97350 /* CounterContollerTitleView.swift */, + D08775181E3F53FC00A97350 /* ContactMultiselectionController.swift */, + D087751B1E3F542500A97350 /* ContactMultiselectionControllerNode.swift */, + ); + name = "Contact Multiselection"; + sourceTree = ""; + }; D08D45281D5E340200A7428A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -976,6 +1095,24 @@ name = "Input Panels"; sourceTree = ""; }; + D0BC38671E3FB9190044D6FE /* Create Group */ = { + isa = PBXGroup; + children = ( + D0BC38691E3FB94D0044D6FE /* CreateGroupController.swift */, + ); + name = "Create Group"; + sourceTree = ""; + }; + D0BC38681E3FB92B0044D6FE /* Compose */ = { + isa = PBXGroup; + children = ( + D087750E1E3F469700A97350 /* Compose */, + D087751A1E3F540900A97350 /* Contact Multiselection */, + D0BC38671E3FB9190044D6FE /* Create Group */, + ); + name = Compose; + sourceTree = ""; + }; D0C932341E0988AD0074F044 /* Button Keyboard */ = { isa = PBXGroup; children = ( @@ -987,6 +1124,9 @@ D0C932391E0B4AC60074F044 /* Settings */ = { isa = PBXGroup; children = ( + D01B279A1E39386C0022A4C0 /* SettingsControllerEntries.swift */, + D01B279C1E394A500022A4C0 /* NotificationsAndSounds.swift */, + D0A749961E3AA25200AD786E /* NotificationSoundSelection.swift */, D0F69E7B1D6B8C470046BCD6 /* SettingsController.swift */, D0F69E7A1D6B8C470046BCD6 /* SettingsAccountInfoItem.swift */, ); @@ -1175,6 +1315,28 @@ name = "Vertical List"; sourceTree = ""; }; + D0E6521D1E3A2305004EEA91 /* Items */ = { + isa = PBXGroup; + children = ( + D003702F1DA43077004308D3 /* ItemListItem.swift */, + D003702D1DA43052004308D3 /* ItemListAvatarAndNameItem.swift */, + D00370311DA46C06004308D3 /* ItemListTextWithLabelItem.swift */, + D03120F51DA534C1006A2A60 /* ItemListActionItem.swift */, + D0B843911DA7F13E005F29E1 /* ItemListDisclosureItem.swift */, + D08774F91E3E2A5600A97350 /* ItemListCheckboxItem.swift */, + D00B3F9F1E3A76D4003872C3 /* ItemListSwitchItem.swift */, + D0B843D81DAAAA0C005F29E1 /* ItemListPeerItem.swift */, + D0B843DA1DAAB138005F29E1 /* ItemListPeerActionItem.swift */, + D00C7CD61E3664070080C3D5 /* ItemListMultilineInputItem.swift */, + D00B3F9D1E3A4847003872C3 /* ItemListSectionHeaderItem.swift */, + D00B3FA11E3A983E003872C3 /* ItemListTextItem.swift */, + D021E0A81E3AACA200AF709C /* ItemListEditableItem.swift */, + D021E0AA1E3B9E2700AF709C /* ItemListRevealOptionsNode.swift */, + D08774F71E3DE7BF00A97350 /* ItemListEditableDeleteControlNode.swift */, + ); + name = Items; + sourceTree = ""; + }; D0E7A1BB1D8C17EB00C37A6F /* Chat History Node */ = { isa = PBXGroup; children = ( @@ -1262,6 +1424,7 @@ D0F69DD31D6B8A160046BCD6 /* Controllers */, D07CFF771DCA226200761F81 /* Chat List Node */, D0E7A1BB1D8C17EB00C37A6F /* Chat History Node */, + D087750D1E3F214200A97350 /* Contact List Node */, ); name = Components; sourceTree = ""; @@ -1281,12 +1444,14 @@ D0F69DC81D6B89EB0046BCD6 /* ImageNode.swift */, D0F69DC61D6B89E70046BCD6 /* TransformImageNode.swift */, D0F69DC41D6B89E10046BCD6 /* RadialProgressNode.swift */, + D00C7CE51E378FD00080C3D5 /* RadialTimeoutNode.swift */, D0F69DC21D6B89DA0046BCD6 /* TextNode.swift */, D0F69DC01D6B89D30046BCD6 /* ListSectionHeaderNode.swift */, D0F69DF71D6B8A880046BCD6 /* AvatarNode.swift */, D0F69DCA1D6B89F20046BCD6 /* Search */, D00219051DDD1C9E00BE708A /* ImageContainingNode.swift */, D0568AAC1DF198130022E7DA /* AudioWaveformNode.swift */, + D0BC38621E3F9EFA0044D6FE /* EditableTokenListNode.swift */, ); name = Nodes; sourceTree = ""; @@ -1343,6 +1508,8 @@ D0F69E0D1D6B8AB90046BCD6 /* Chat */, D0F69E4E1D6B8BB90046BCD6 /* Media */, D0F69E6C1D6B8C220046BCD6 /* Contacts */, + D0BC38681E3FB92B0044D6FE /* Compose */, + D01B27931E38F3920022A4C0 /* Item List */, D0EE97131D88BB1A006C18E1 /* Peer Info */, D0D2689B1D79D31500C422DA /* Peer Selection */, D0F69E791D6B8C3B0046BCD6 /* Settings */, @@ -1405,6 +1572,7 @@ D0D268681D78865300C422DA /* ChatAvatarNavigationNode.swift */, D0DE76FF1D92F1EB002B8809 /* ChatTitleView.swift */, D02383761DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift */, + D00C7CE81E379B820080C3D5 /* ChatSecretAutoremoveTimerActionSheet.swift */, D0EE97191D88BCA0006C18E1 /* ChatInfo.swift */, D0F69E181D6B8AD10046BCD6 /* Items */, D03ADB461D703250005A521C /* Interface State */, @@ -1500,6 +1668,7 @@ D0F69E521D6B8BDA0046BCD6 /* GalleryItem.swift */, D0F69E531D6B8BDA0046BCD6 /* GalleryItemNode.swift */, D0F69E541D6B8BDA0046BCD6 /* GalleryPagerNode.swift */, + D00C7CDA1E3776CA0080C3D5 /* Secret Preview */, D0F69E5A1D6B8BDD0046BCD6 /* Items */, ); name = Gallery; @@ -1532,10 +1701,7 @@ children = ( D0F69E6D1D6B8C340046BCD6 /* ContactsController.swift */, D0F69E6E1D6B8C340046BCD6 /* ContactsControllerNode.swift */, - D0F69E6F1D6B8C340046BCD6 /* ContactsPeerItem.swift */, D0F69E701D6B8C340046BCD6 /* ContactsSearchContainerNode.swift */, - D0F69E711D6B8C340046BCD6 /* ContactsSectionHeaderAccessoryItem.swift */, - D0F69E721D6B8C340046BCD6 /* ContactsVCardItem.swift */, ); name = Contacts; sourceTree = ""; @@ -1564,6 +1730,8 @@ D0F69E861D6B8C850046BCD6 /* RingBuffer.m */, D0F69E871D6B8C850046BCD6 /* RingByteBuffer.swift */, D0F69EA51D6B8F3E0046BCD6 /* TelegramUIIncludes.h */, + D00C7CF51E37BF680080C3D5 /* SecretChatKeyVisualization.h */, + D00C7CF61E37BF680080C3D5 /* SecretChatKeyVisualization.m */, ); name = "Supporting Files"; sourceTree = ""; @@ -1572,6 +1740,7 @@ isa = PBXGroup; children = ( D0B844551DAC3AEE005F29E1 /* PresenceStrings.swift */, + D08775081E3E59DE00A97350 /* PeerNotificationSoundStrings.swift */, D0F69E931D6B8C9B0046BCD6 /* ProgressiveImage.swift */, D0F69E941D6B8C9B0046BCD6 /* WebP.swift */, D0B844571DAC44E8005F29E1 /* PeerPresenceStatusManager.swift */, @@ -1586,6 +1755,7 @@ D0F917B41E0DA396003687E6 /* GenerateTextEntities.swift */, D017494D1E1059570057C89A /* StringWithAppliedEntities.swift */, D01749541E1082770057C89A /* StoredMessageFromSearchPeer.swift */, + D087750B1E3E7B7600A97350 /* PreferencesKeys.swift */, ); name = Utils; sourceTree = ""; @@ -1629,6 +1799,7 @@ children = ( D07551891DDA4C7C0073E051 /* Legacy Components */, D0F69E911D6B8C8E0046BCD6 /* Utils */, + D087750A1E3E7A6D00A97350 /* Settings */, D0F69DBB1D6B88330046BCD6 /* Media */, D0F69DBD1D6B897A0046BCD6 /* Components */, D0F69DE61D6B8A4E0046BCD6 /* Controllers */, @@ -1675,6 +1846,7 @@ D0D03B191DECB0FE00220C46 /* opus_types.h in Headers */, D0D03B171DECB0FE00220C46 /* opus_defines.h in Headers */, D0D03B091DECB0FE00220C46 /* diag_range.h in Headers */, + D00C7CF71E37BF680080C3D5 /* SecretChatKeyVisualization.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1785,12 +1957,16 @@ files = ( D01749621E11DB240057C89A /* NetworkStatusTitleView.swift in Sources */, D0B417C31D7DE54E004562A4 /* ChatPresentationInterfaceState.swift in Sources */, + D00B3F9E1E3A4847003872C3 /* ItemListSectionHeaderItem.swift in Sources */, D0F69E3C1D6B8B030046BCD6 /* ChatMessageTextBubbleContentNode.swift in Sources */, + D08775101E3F46A400A97350 /* ComposeController.swift in Sources */, D03ADB4D1D7045C9005A521C /* ChatInterfaceStateAccessoryPanels.swift in Sources */, D0F7AB391DCFF87B009AD9A1 /* ChatMessageDateHeader.swift in Sources */, D0DF0C9A1D81FF3F008AEB01 /* ChatInputContextPanelNode.swift in Sources */, D0D03B1B1DECB0FE00220C46 /* info.c in Sources */, D0215D4A1E041CAF001A0B1E /* InstantPageMediaItem.swift in Sources */, + D087751E1E3F579300A97350 /* CounterContollerTitleView.swift in Sources */, + D08775141E3F4A7700A97350 /* ContactListNameIndexHeader.swift in Sources */, D0215D461E041851001A0B1E /* InstantPageTextItem.swift in Sources */, D021E0D21DB4147500C6B04F /* ChatInterfaceInputNodes.swift in Sources */, D0D2689D1D79D33E00C422DA /* ShareRecipientsActionSheetController.swift in Sources */, @@ -1809,17 +1985,20 @@ D0B844581DAC44E8005F29E1 /* PeerPresenceStatusManager.swift in Sources */, D0D2686C1D788F8200C422DA /* ChatTitleAccessoryPanelNode.swift in Sources */, D0215D541E043018001A0B1E /* InstantPageController.swift in Sources */, + D00C7CE91E379B820080C3D5 /* ChatSecretAutoremoveTimerActionSheet.swift in Sources */, D07A7DA51D95783C005BCD27 /* ListMessageNode.swift in Sources */, D0C9323C1E0B4AE90074F044 /* DataAndStorageSettingsController.swift in Sources */, D01F66131DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift in Sources */, D0F69E341D6B8B030046BCD6 /* ChatMessageForwardInfoNode.swift in Sources */, + D00C7CF81E37BF680080C3D5 /* SecretChatKeyVisualization.m in Sources */, D0736F251DF4D0E500F2C02A /* TelegramController.swift in Sources */, + D021E0AB1E3B9E2700AF709C /* ItemListRevealOptionsNode.swift in Sources */, D0F69E561D6B8BDA0046BCD6 /* GalleryControllerNode.swift in Sources */, D0F69E4D1D6B8BB20046BCD6 /* ChatMediaActionSheetRollItem.swift in Sources */, D0F69E661D6B8BF90046BCD6 /* ZoomableContentGalleryItemNode.swift in Sources */, D0DE77001D92F1EB002B8809 /* ChatTitleView.swift in Sources */, D0F69DF01D6B8A6C0046BCD6 /* AuthorizationCodeControllerNode.swift in Sources */, - D0B843DB1DAAB138005F29E1 /* PeerInfoPeerActionItem.swift in Sources */, + D0B843DB1DAAB138005F29E1 /* ItemListPeerActionItem.swift in Sources */, D0F69EA11D6B8E380046BCD6 /* FileResources.swift in Sources */, D0F69D271D6B87D30046BCD6 /* FFMpegAudioFrameDecoder.swift in Sources */, D0DC354C1DE366DE000195EB /* CommandChatInputPanelItem.swift in Sources */, @@ -1841,12 +2020,13 @@ D0F69DA41D6B87EC0046BCD6 /* FFMpegMediaVideoFrameDecoder.swift in Sources */, D099EA2D1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift in Sources */, D0F69E161D6B8ACF0046BCD6 /* ChatHistoryEntry.swift in Sources */, + D021E0A91E3AACA200AF709C /* ItemListEditableItem.swift in Sources */, D021E0CE1DB4135500C6B04F /* ChatMediaInputNode.swift in Sources */, D0F69DE01D6B8A420046BCD6 /* ListControllerButtonItem.swift in Sources */, D0F69E0C1D6B8AB10046BCD6 /* HorizontalPeerItem.swift in Sources */, D0F69E551D6B8BDA0046BCD6 /* GalleryController.swift in Sources */, D0F69E571D6B8BDA0046BCD6 /* GalleryItem.swift in Sources */, - D003702E1DA43052004308D3 /* PeerInfoAvatarAndNameItem.swift in Sources */, + D003702E1DA43052004308D3 /* ItemListAvatarAndNameItem.swift in Sources */, D0D03B1C1DECB0FE00220C46 /* internal.c in Sources */, D0F69D231D6B87D30046BCD6 /* FFMpegMediaFrameSourceContext.swift in Sources */, D0F69E8D1D6B8C850046BCD6 /* Localizable.swift in Sources */, @@ -1860,10 +2040,12 @@ D07CFF871DCAAE5E00761F81 /* ForwardAccessoryPanelNode.swift in Sources */, D0D03B0A1DECB0FE00220C46 /* opus_header.c in Sources */, D0F69D9C1D6B87EC0046BCD6 /* MediaPlaybackData.swift in Sources */, + D0BC386A1E3FB94D0044D6FE /* CreateGroupController.swift in Sources */, D0F69D241D6B87D30046BCD6 /* MediaPlayerAudioRenderer.swift in Sources */, D0DF0CA41D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift in Sources */, D01749571E1087CC0057C89A /* ChatBotStartInputPanelNode.swift in Sources */, D0F69D4B1D6B87D30046BCD6 /* TouchDownGestureRecognizer.swift in Sources */, + D01B279D1E394A500022A4C0 /* NotificationsAndSounds.swift in Sources */, D0F69E3D1D6B8B030046BCD6 /* ChatMessageWebpageBubbleContentNode.swift in Sources */, D08C36811DB66AAC0064C744 /* ChatMediaInputGridEntries.swift in Sources */, D099EA1F1DE7450B001AF5A8 /* HorizontalListContextResultsChatInputContextPanelNode.swift in Sources */, @@ -1872,7 +2054,7 @@ D0D2686E1D7898A900C422DA /* ChatMessageSelectionNode.swift in Sources */, D0EFD8961DDE8249009E508A /* LegacyLocationPicker.swift in Sources */, D0F69E8B1D6B8C850046BCD6 /* FFMpegSwResample.m in Sources */, - D0B843921DA7F13E005F29E1 /* PeerInfoDisclosureItem.swift in Sources */, + D0B843921DA7F13E005F29E1 /* ItemListDisclosureItem.swift in Sources */, D07CFF791DCA226F00761F81 /* ChatListNode.swift in Sources */, D0B7F8E81D8A1F5F0045D939 /* PeerMediaCollectionControllerNode.swift in Sources */, D0215D561E043020001A0B1E /* InstantPageControllerNode.swift in Sources */, @@ -1888,19 +2070,24 @@ D07551931DDA540F0073E051 /* TelegramInitializeLegacyComponents.swift in Sources */, D039EB0A1DEC7A8700886EBC /* ChatTextInputAudioRecordingCancelIndicator.swift in Sources */, D0F69E011D6B8A880046BCD6 /* ChatListEmptyItem.swift in Sources */, + D00C7CDC1E3776E50080C3D5 /* SecretMediaPreviewController.swift in Sources */, D0215D3E1E041048001A0B1E /* InstantPageMedia.swift in Sources */, + D00C7CD91E36B2DB0080C3D5 /* ContactListNode.swift in Sources */, D0C932361E0988C60074F044 /* ChatButtonKeyboardInputNode.swift in Sources */, D0F69E591D6B8BDA0046BCD6 /* GalleryPagerNode.swift in Sources */, D0F69E391D6B8B030046BCD6 /* ChatMessageMediaBubbleContentNode.swift in Sources */, D0F69D351D6B87D30046BCD6 /* MediaFrameSource.swift in Sources */, D0736F2A1DF4D5FF00F2C02A /* MediaNavigationAccessoryPanel.swift in Sources */, + D087751C1E3F542500A97350 /* ContactMultiselectionControllerNode.swift in Sources */, D0E7A1BD1D8C246D00C37A6F /* ChatHistoryListNode.swift in Sources */, D0F69E371D6B8B030046BCD6 /* ChatMessageItem.swift in Sources */, D099EA291DE76655001AF5A8 /* ManagedVideoNode.swift in Sources */, D0215D501E0422C7001A0B1E /* InstantPageWebEmbedItem.swift in Sources */, + D0BC38631E3F9EFA0044D6FE /* EditableTokenListNode.swift in Sources */, D023ED2E1DDB5BEC00BD496D /* LegacyAttachmentMenu.swift in Sources */, D02383771DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift in Sources */, - D00370321DA46C06004308D3 /* PeerInfoTextWithLabelItem.swift in Sources */, + D00370321DA46C06004308D3 /* ItemListTextWithLabelItem.swift in Sources */, + D00B3FA21E3A983E003872C3 /* ItemListTextItem.swift in Sources */, D0DF0C9C1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift in Sources */, D0DE77301D934DEF002B8809 /* ListMessageItem.swift in Sources */, D099EA211DE7451D001AF5A8 /* HorizontalListContextResultsChatInputPanelItem.swift in Sources */, @@ -1913,6 +2100,7 @@ D0DF0CA11D821B28008AEB01 /* HashtagChatInputPanelItem.swift in Sources */, D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */, D0F69E351D6B8B030046BCD6 /* ChatMessageInteractiveFileNode.swift in Sources */, + D08775121E3F46AB00A97350 /* ComposeControllerNode.swift in Sources */, D01749531E1068820057C89A /* HashtagSearchControllerNode.swift in Sources */, D0F69E151D6B8ACF0046BCD6 /* ChatControllerNode.swift in Sources */, D02383731DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift in Sources */, @@ -1933,6 +2121,7 @@ D0F69E041D6B8A880046BCD6 /* ChatListSearchItem.swift in Sources */, D0F69E611D6B8BF90046BCD6 /* ChatDocumentGalleryItem.swift in Sources */, D0F69E0A1D6B8AA60046BCD6 /* ChatListSearchRecentPeersNode.swift in Sources */, + D00C7CE61E378FD00080C3D5 /* RadialTimeoutNode.swift in Sources */, D0F69E3E1D6B8B030046BCD6 /* ChatUnreadItem.swift in Sources */, D0D03B0D1DECB0FE00220C46 /* opusenc.m in Sources */, D0B7F8E21D8A18070045D939 /* PeerMediaCollectionController.swift in Sources */, @@ -1949,11 +2138,14 @@ D0F69E2F1D6B8B030046BCD6 /* ChatMessageBubbleContentCalclulateImageCorners.swift in Sources */, D075518B1DDA4D7D0073E051 /* LegacyController.swift in Sources */, D0F69E361D6B8B030046BCD6 /* ChatMessageInteractiveMediaNode.swift in Sources */, + D08775191E3F53FC00A97350 /* ContactMultiselectionController.swift in Sources */, + D087750C1E3E7B7600A97350 /* PreferencesKeys.swift in Sources */, D0F69E381D6B8B030046BCD6 /* ChatMessageItemView.swift in Sources */, D0D268671D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift in Sources */, D039EB081DEC725600886EBC /* ChatTextInputAudioRecordingTimeNode.swift in Sources */, D07827BD1E004A3400071108 /* ChatListSearchItemHeader.swift in Sources */, D0F69E901D6B8C850046BCD6 /* RingByteBuffer.swift in Sources */, + D08774F81E3DE7BF00A97350 /* ItemListEditableDeleteControlNode.swift in Sources */, D0DF0C981D81FF28008AEB01 /* HashtagChatInputContextPanelNode.swift in Sources */, D0F69E731D6B8C340046BCD6 /* ContactsController.swift in Sources */, D0F69D261D6B87D30046BCD6 /* MediaManager.swift in Sources */, @@ -1967,22 +2159,28 @@ D0F917B51E0DA396003687E6 /* GenerateTextEntities.swift in Sources */, D0736F2E1DF4E54A00F2C02A /* MediaNavigationAccessoryHeaderNode.swift in Sources */, D0F69DF51D6B8A6C0046BCD6 /* AuthorizationPhoneControllerNode.swift in Sources */, + D01B279B1E39386C0022A4C0 /* SettingsControllerEntries.swift in Sources */, + D08775201E3F595000A97350 /* ContactListActionItem.swift in Sources */, D0F69E751D6B8C340046BCD6 /* ContactsPeerItem.swift in Sources */, + D01B27991E39144C0022A4C0 /* ItemListController.swift in Sources */, D0F69DD61D6B8A2D0046BCD6 /* AlertController.swift in Sources */, - D00370301DA43077004308D3 /* PeerInfoItem.swift in Sources */, + D00370301DA43077004308D3 /* ItemListItem.swift in Sources */, D0215D381E040F53001A0B1E /* InstantPageNode.swift in Sources */, D0F69E7D1D6B8C470046BCD6 /* SettingsController.swift in Sources */, D0F69E8C1D6B8C850046BCD6 /* FrameworkBundle.swift in Sources */, D0D03B101DECB0FE00220C46 /* wav_io.c in Sources */, D0F69D661D6B87D30046BCD6 /* FFMpegMediaFrameSourceContextHelpers.swift in Sources */, - D0B843D91DAAAA0C005F29E1 /* PeerInfoPeerItem.swift in Sources */, + D0B843D91DAAAA0C005F29E1 /* ItemListPeerItem.swift in Sources */, D0F69DD11D6B8A0D0046BCD6 /* SearchDisplayController.swift in Sources */, + D08775091E3E59DE00A97350 /* PeerNotificationSoundStrings.swift in Sources */, + D0A749971E3AA25200AD786E /* NotificationSoundSelection.swift in Sources */, D0DE77271D932627002B8809 /* ChatHistoryNode.swift in Sources */, D07CFF7D1DCA273400761F81 /* ChatListViewTransition.swift in Sources */, D0DF0C951D81B063008AEB01 /* ChatInterfaceStateContextMenus.swift in Sources */, D0F69DF21D6B8A6C0046BCD6 /* AuthorizationPasswordController.swift in Sources */, D0DE772B1D932E16002B8809 /* PeerMediaCollectionModeSelectionNode.swift in Sources */, D0F69E8F1D6B8C850046BCD6 /* RingBuffer.m in Sources */, + D01B279F1E394BD70022A4C0 /* InAppNotificationSettings.swift in Sources */, D0F69DF31D6B8A6C0046BCD6 /* AuthorizationPasswordControllerNode.swift in Sources */, D0F69E131D6B8ACF0046BCD6 /* ChatController.swift in Sources */, D023837E1DDF50FD004018B6 /* ChatToastAlertPanelNode.swift in Sources */, @@ -1991,12 +2189,16 @@ D01749511E1067E40057C89A /* HashtagSearchController.swift in Sources */, D0D03B0E1DECB0FE00220C46 /* picture.c in Sources */, D0F69DF11D6B8A6C0046BCD6 /* AuthorizationController.swift in Sources */, + D01B27A41E394FC90022A4C0 /* SecuritySettings.swift in Sources */, + D08774FA1E3E2A5600A97350 /* ItemListCheckboxItem.swift in Sources */, D073CE711DCBF23F007511FD /* DeclareEncodables.swift in Sources */, D0E7A1BF1D8C24B900C37A6F /* ChatHistoryViewForLocation.swift in Sources */, D0F69E891D6B8C850046BCD6 /* FastBlur.m in Sources */, D07CFF761DCA224100761F81 /* PeerSelectionControllerNode.swift in Sources */, D0F69E7C1D6B8C470046BCD6 /* SettingsAccountInfoItem.swift in Sources */, + D00C7CDE1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift in Sources */, D0B843D31DA922E3005F29E1 /* ChannelInfoEntries.swift in Sources */, + D01B27951E38F3BF0022A4C0 /* ItemListControllerNode.swift in Sources */, D0B843D11DA922D7005F29E1 /* UserInfoEntries.swift in Sources */, D0F69E6A1D6B8C160046BCD6 /* MapInputController.swift in Sources */, D00219041DDCC86400BE708A /* PerformanceSpinner.swift in Sources */, @@ -2029,7 +2231,7 @@ D01AC91F1DD5E09000E8160F /* EditAccessoryPanelNode.swift in Sources */, D023EBB21DDA800700BD496D /* LegacyMediaPickers.swift in Sources */, D0F69DD01D6B8A0D0046BCD6 /* SearchBarPlaceholderNode.swift in Sources */, - D03120F61DA534C1006A2A60 /* PeerInfoActionItem.swift in Sources */, + D03120F61DA534C1006A2A60 /* ItemListActionItem.swift in Sources */, D0F69E781D6B8C340046BCD6 /* ContactsVCardItem.swift in Sources */, D0D268691D78865300C422DA /* ChatAvatarNavigationNode.swift in Sources */, D0DC35441DE32230000195EB /* ChatInterfaceStateContextQueries.swift in Sources */, @@ -2041,6 +2243,7 @@ D0F69DE21D6B8A420046BCD6 /* ListControllerGroupableItem.swift in Sources */, D0F69D791D6B87DF0046BCD6 /* MediaTrackFrame.swift in Sources */, D0215D3A1E041003001A0B1E /* InstantPageLayout.swift in Sources */, + D00B3FA01E3A76D4003872C3 /* ItemListSwitchItem.swift in Sources */, D0F69DC91D6B89EB0046BCD6 /* ImageNode.swift in Sources */, D0DE77251D93225E002B8809 /* PeerMediaCollectionInterfaceStateButtons.swift in Sources */, D03ADB4B1D70443F005A521C /* ReplyAccessoryPanelNode.swift in Sources */, @@ -2070,6 +2273,7 @@ D00219061DDD1C9E00BE708A /* ImageContainingNode.swift in Sources */, D0F69E2E1D6B8B030046BCD6 /* ChatMessageAvatarAccessoryItem.swift in Sources */, D0F69D2E1D6B87D30046BCD6 /* PeerAvatar.swift in Sources */, + D00C7CD71E3664070080C3D5 /* ItemListMultilineInputItem.swift in Sources */, D0F69E141D6B8ACF0046BCD6 /* ChatControllerInteraction.swift in Sources */, D0F69D6D1D6B87D30046BCD6 /* MediaTrackDecodableFrame.swift in Sources */, D0D03B201DECB0FE00220C46 /* stream.c in Sources */, diff --git a/TelegramUI/AvatarNode.swift b/TelegramUI/AvatarNode.swift index 5011e2091e..90481cc1d4 100644 --- a/TelegramUI/AvatarNode.swift +++ b/TelegramUI/AvatarNode.swift @@ -22,7 +22,7 @@ private class AvatarNodeParameters: NSObject { } } -let gradientColors: [NSArray] = [ +private let gradientColors: [NSArray] = [ [UIColor(0xff516a).cgColor, UIColor(0xff885e).cgColor], [UIColor(0xffa85c).cgColor, UIColor(0xffcd6a).cgColor], [UIColor(0x54cb68).cgColor, UIColor(0xa0de7e).cgColor], @@ -31,6 +31,10 @@ let gradientColors: [NSArray] = [ [UIColor(0xd669ed).cgColor, UIColor(0xe0a2f3).cgColor] ] +private let grayscaleColors: NSArray = [ + UIColor(0xefefef).cgColor, UIColor(0xeeeeee).cgColor +] + private enum AvatarNodeState: Equatable { case Empty case PeerAvatar(PeerId, [String], TelegramMediaImageRepresentation?) @@ -150,12 +154,21 @@ public final class AvatarNode: ASDisplayNode { let colorIndex: Int if let parameters = parameters as? AvatarNodeParameters { - colorIndex = Int(parameters.account.peerId.id + parameters.peerId.id) + if parameters.peerId.id == 0 { + colorIndex = -1 + } else { + colorIndex = abs(Int(parameters.account.peerId.id + parameters.peerId.id)) + } } else { - colorIndex = 0 + colorIndex = -1 } - let colorsArray: NSArray = gradientColors[colorIndex % gradientColors.count] + let colorsArray: NSArray + if colorIndex == -1 { + colorsArray = grayscaleColors + } else { + colorsArray = gradientColors[colorIndex % gradientColors.count] + } var locations: [CGFloat] = [1.0, 0.2]; diff --git a/TelegramUI/ChannelInfoEntries.swift b/TelegramUI/ChannelInfoEntries.swift index a5c6832824..763eabb0ab 100644 --- a/TelegramUI/ChannelInfoEntries.swift +++ b/TelegramUI/ChannelInfoEntries.swift @@ -4,24 +4,11 @@ import TelegramCore import SwiftSignalKit import Display -private enum ChannelInfoSection: UInt32, PeerInfoSection { +private enum ChannelInfoSection: ItemListSectionId { case info case sharedMediaAndNotifications + case members case reportOrLeave - - func isEqual(to: PeerInfoSection) -> Bool { - guard let section = to as? ChannelInfoSection else { - return false - } - return section == self - } - - func isOrderedBefore(_ section: PeerInfoSection) -> Bool { - guard let section = section as? ChannelInfoSection else { - return false - } - return self.rawValue < section.rawValue - } } enum ChannelInfoEntry: PeerInfoEntry { @@ -31,16 +18,19 @@ enum ChannelInfoEntry: PeerInfoEntry { case sharedMedia case notifications(settings: PeerNotificationSettings?) case report + case member(index: Int, peerId: PeerId, peer: Peer?, presence: PeerPresence?, memberStatus: GroupInfoMemberStatus) case leave - var section: PeerInfoSection { + var section: ItemListSectionId { switch self { case .info, .about, .userName: - return ChannelInfoSection.info + return ChannelInfoSection.info.rawValue case .sharedMedia, .notifications: - return ChannelInfoSection.sharedMediaAndNotifications + return ChannelInfoSection.sharedMediaAndNotifications.rawValue + case .member: + return ChannelInfoSection.members.rawValue case .report, .leave: - return ChannelInfoSection.reportOrLeave + return ChannelInfoSection.reportOrLeave.rawValue } } @@ -107,6 +97,26 @@ enum ChannelInfoEntry: PeerInfoEntry { default: return false } + case let .member(lhsIndex, lhsPeerId, lhsPeer, lhsPresence, lhsMemberStatus): + if case let .member(rhsIndex, rhsPeerId, rhsPeer, rhsPresence, rhsMemberStatus) = entry, lhsIndex == rhsIndex && lhsPeerId == rhsPeerId, lhsMemberStatus == rhsMemberStatus { + if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + } else if (lhsPeer == nil) != (rhsPeer != nil) { + return false + } + if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence { + if !lhsPresence.isEqual(to: rhsPresence) { + return false + } + } else if (lhsPresence == nil) != (rhsPresence != nil) { + return false + } + return true + } else { + return false + } case .report: switch entry { case .report: @@ -131,15 +141,17 @@ enum ChannelInfoEntry: PeerInfoEntry { case .about: return 1 case .userName: - return 1000 + return 2 case .sharedMedia: - return 1004 + return 3 case .notifications: - return 1005 + return 4 + case let .member(index, _, _, _, _): + return 100 + index case .report: - return 1006 + return 1001 case .leave: - return 1007 + return 1002 } } @@ -153,16 +165,18 @@ enum ChannelInfoEntry: PeerInfoEntry { func item(account: Account, interaction: PeerInfoControllerInteraction) -> ListViewItem { switch self { case let .info(peer, cachedData): - return PeerInfoAvatarAndNameItem(account: account, peer: peer, cachedData: cachedData, editingState: nil, sectionId: self.section.rawValue, style: .plain) + return ItemListAvatarAndNameInfoItem(account: account, peer: peer, cachedData: cachedData, state: ItemListAvatarAndNameInfoItemState(editingName: nil, updatingName: nil), sectionId: self.section, style: .plain, editingNameUpdated: { editingName in + + }) case let .about(text): - return PeerInfoTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: self.section.rawValue) + return ItemListTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: self.section) case let .userName(value): - return PeerInfoTextWithLabelItem(label: "share link", text: "https://telegram.me/\(value)", multiline: false, sectionId: self.section.rawValue) - return PeerInfoActionItem(title: "Start Secret Chat", kind: .generic, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListTextWithLabelItem(label: "share link", text: "https://telegram.me/\(value)", multiline: false, sectionId: self.section) + return ItemListActionItem(title: "Start Secret Chat", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { }) case .sharedMedia: - return PeerInfoDisclosureItem(title: "Shared Media", label: "", sectionId: self.section.rawValue, style: .plain, action: { + return ItemListDisclosureItem(title: "Shared Media", label: "", sectionId: self.section, style: .plain, action: { interaction.openSharedMedia() }) case let .notifications(settings): @@ -172,15 +186,28 @@ enum ChannelInfoEntry: PeerInfoEntry { } else { label = "Enabled" } - return PeerInfoDisclosureItem(title: "Notifications", label: label, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListDisclosureItem(title: "Notifications", label: label, sectionId: self.section, style: .plain, action: { interaction.changeNotificationMuteSettings() }) + case let .member(_, peerId, peer, presence, memberStatus): + let label: String? + switch memberStatus { + case .admin: + label = "admin" + case .member: + label = nil + } + return ItemListPeerItem(account: account, peer: peer, presence: presence, label: label, sectionId: self.section, action: { + if let peer = peer { + interaction.openPeerInfo(peer.id) + } + }) case .report: - return PeerInfoActionItem(title: "Report", kind: .generic, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListActionItem(title: "Report", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { }) case .leave: - return PeerInfoActionItem(title: "Leave Channel", kind: .destructive, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListActionItem(title: "Leave Channel", kind: .destructive, alignment: .natural, sectionId: self.section, style: .plain, action: { }) } diff --git a/TelegramUI/ChatButtonKeyboardInputNode.swift b/TelegramUI/ChatButtonKeyboardInputNode.swift index 987019457d..904b4edff6 100644 --- a/TelegramUI/ChatButtonKeyboardInputNode.swift +++ b/TelegramUI/ChatButtonKeyboardInputNode.swift @@ -154,11 +154,11 @@ final class ChatButtonKeyboardInputNode: ChatInputNode { controllerInteraction.shareAccountContact() case .openWebApp: if let message = self.message { - controllerInteraction.requestMessageActionCallback(message.id, nil) + controllerInteraction.requestMessageActionCallback(message.id, nil, true) } case let .callback(data): if let message = self.message { - controllerInteraction.requestMessageActionCallback(message.id, data) + controllerInteraction.requestMessageActionCallback(message.id, data, false) } case let .switchInline(samePeer, query): if let message = message { diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 1c80946695..632d332256 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -33,6 +33,7 @@ public class ChatController: TelegramController { private var historyStateDisposable: Disposable? private let galleryHiddenMesageAndMediaDisposable = MetaDisposable() + private weak var secretMediaPreviewController: SecretMediaPreviewController? private var controllerInteraction: ChatControllerInteraction? private var interfaceInteraction: ChatPanelInterfaceInteraction? @@ -60,6 +61,8 @@ public class ChatController: TelegramController { private var audioRecorderDisposable: Disposable? private var buttonKeyboardMessageDisposable: Disposable? + private var chatUnreadCountDisposable: Disposable? + private var peerInputActivitiesDisposable: Disposable? public init(account: Account, peerId: PeerId, messageId: MessageId? = nil, botStart: ChatControllerInitialBotStart? = nil) { self.account = account @@ -146,6 +149,29 @@ public class ChatController: TelegramController { } } } + }, openSecretMessagePreview: { [weak self] messageId in + if let strongSelf = self { + var galleryMedia: Media? + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { + for media in message.media { + if let file = media as? TelegramMediaFile, file.isVideo { + galleryMedia = file + } else if let image = media as? TelegramMediaImage { + galleryMedia = image + } + } + } + if let _ = galleryMedia { + let gallery = SecretMediaPreviewController(account: strongSelf.account, messageId: messageId) + strongSelf.secretMediaPreviewController = gallery + strongSelf.present(gallery, in: .window) + } + } + }, closeSecretMessagePreview: { [weak self] in + if let strongSelf = self { + strongSelf.secretMediaPreviewController?.dismiss() + strongSelf.secretMediaPreviewController = nil + } }, openPeer: { [weak self] id, navigation in if let strongSelf = self { strongSelf.openPeer(id, navigation) @@ -240,7 +266,7 @@ public class ChatController: TelegramController { }) enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", attributes: [], media: file, replyToMessageId: strongSelf.presentationInterfaceState.interfaceState.replyMessageId)]).start() } - }, requestMessageActionCallback: { [weak self] messageId, data in + }, requestMessageActionCallback: { [weak self] messageId, data, isGame in if let strongSelf = self { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { return $0.updatedTitlePanelContext { @@ -260,7 +286,7 @@ public class ChatController: TelegramController { } }) - strongSelf.messageActionCallbackDisposable.set((requestMessageActionCallback(account: strongSelf.account, messageId: messageId, data: data) |> afterDisposed { + strongSelf.messageActionCallbackDisposable.set((requestMessageActionCallback(account: strongSelf.account, messageId: messageId, isGame: isGame, data: data) |> afterDisposed { Queue.mainQueue().async { if let strongSelf = self { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { @@ -406,7 +432,7 @@ public class ChatController: TelegramController { peerDisposable.set((self.peerView.get() |> deliverOnMainQueue).start(next: { [weak self] peerView in if let strongSelf = self { - if let peer = peerView.peers[peerId] { + if let peer = peerViewMainPeer(peerView) { strongSelf.chatTitleView?.peerView = peerView (strongSelf.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.avatarNode.setPeer(account: strongSelf.account, peer: peer) } @@ -532,6 +558,8 @@ public class ChatController: TelegramController { self.audioRecorderDisposable?.dispose() self.buttonKeyboardMessageDisposable?.dispose() self.resolveUrlDisposable?.dispose() + self.chatUnreadCountDisposable?.dispose() + self.peerInputActivitiesDisposable?.dispose() } var chatDisplayNode: ChatControllerNode { @@ -781,9 +809,7 @@ public class ChatController: TelegramController { }, deleteSelectedMessages: { [weak self] in if let strongSelf = self { if let messageIds = strongSelf.presentationInterfaceState.interfaceState.selectionState?.selectedIds, !messageIds.isEmpty { - strongSelf.account.postbox.modify({ modifier in - modifier.deleteMessages(Array(messageIds)) - }).start() + deleteMessagesInteractively(postbox: strongSelf.account.postbox, messageIds: Array(messageIds), type: .forLocalPeer).start() } strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withoutSelectionState() } }) } @@ -902,8 +928,74 @@ public class ChatController: TelegramController { self?.requestAudioRecorder() }, finishAudioRecording: { [weak self] sendAudio in self?.dismissAudioRecorder(sendAudio: sendAudio) + }, setupMessageAutoremoveTimeout: { [weak self] in + if let strongSelf = self, strongSelf.peerId.namespace == Namespaces.Peer.SecretChat { + strongSelf.chatDisplayNode.dismissInput() + + if let peer = strongSelf.presentationInterfaceState.peer as? TelegramSecretChat { + let controller = ChatSecretAutoremoveTimerActionSheetController(currentValue: peer.messageAutoremoveTimeout == nil ? 0 : peer.messageAutoremoveTimeout!, applyValue: { value in + if let strongSelf = self { + setSecretChatMessageAutoremoveTimeoutInteractively(account: strongSelf.account, peerId: strongSelf.peerId, timeout: value == 0 ? nil : value).start() + } + }) + strongSelf.present(controller, in: .window) + } + } }, statuses: ChatPanelInterfaceInteractionStatuses(editingMessage: self.editingMessage.get(), startingBot: self.startingBot.get())) + self.chatUnreadCountDisposable = (self.account.postbox.unreadMessageCountsView(items: [.peer(self.peerId)]) |> deliverOnMainQueue).start(next: { [weak self] items in + if let strongSelf = self { + var unreadCount: Int32 = 0 + if let count = items.count(for: .peer(strongSelf.peerId)) { + unreadCount = count + } + if unreadCount != 0 { + strongSelf.chatDisplayNode.navigateToLatestButton.badge = "\(unreadCount)" + } else { + strongSelf.chatDisplayNode.navigateToLatestButton.badge = "" + } + } + }) + + let postbox = self.account.postbox + var previousPeerCache = Atomic<[PeerId: Peer]>(value: [:]) + self.peerInputActivitiesDisposable = (self.account.peerInputActivities(peerId: peerId) + |> mapToSignal { activities -> Signal<[(Peer, PeerInputActivity)], NoError> in + var foundAllPeers = true + var cachedResult: [(Peer, PeerInputActivity)] = [] + previousPeerCache.with { dict -> Void in + for (peerId, activity) in activities { + if let peer = dict[peerId] { + cachedResult.append((peer, activity)) + } else { + foundAllPeers = false + break + } + } + } + if foundAllPeers { + return .single(cachedResult) + } else { + return postbox.modify { modifier -> [(Peer, PeerInputActivity)] in + var result: [(Peer, PeerInputActivity)] = [] + var peerCache: [PeerId: Peer] = [:] + for (peerId, activity) in activities { + if let peer = modifier.getPeer(peerId) { + result.append((peer, activity)) + peerCache[peerId] = peer + } + } + previousPeerCache.swap(peerCache) + return result + } + } + } + |> deliverOnMainQueue).start(next: { [weak self] activities in + if let strongSelf = self { + strongSelf.chatTitleView?.inputActivities = (strongSelf.peerId, activities) + } + }) + self.interfaceInteraction = interfaceInteraction self.chatDisplayNode.interfaceInteraction = interfaceInteraction diff --git a/TelegramUI/ChatControllerInteraction.swift b/TelegramUI/ChatControllerInteraction.swift index 4dc4979032..d7bb2e16c6 100644 --- a/TelegramUI/ChatControllerInteraction.swift +++ b/TelegramUI/ChatControllerInteraction.swift @@ -21,6 +21,8 @@ public enum ChatControllerInteractionNavigateToPeer { public final class ChatControllerInteraction { let openMessage: (MessageId) -> Void + let openSecretMessagePreview: (MessageId) -> Void + let closeSecretMessagePreview: () -> Void let openPeer: (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void let openPeerMention: (String) -> Void let openMessageContextMenu: (MessageId, ASDisplayNode, CGRect) -> Void @@ -31,7 +33,7 @@ public final class ChatControllerInteraction { let toggleMessageSelection: (MessageId) -> Void let sendMessage: (String) -> Void let sendSticker: (TelegramMediaFile) -> Void - let requestMessageActionCallback: (MessageId, MemoryBuffer?) -> Void + let requestMessageActionCallback: (MessageId, MemoryBuffer?, Bool) -> Void let openUrl: (String) -> Void let shareCurrentLocation: () -> Void let shareAccountContact: () -> Void @@ -40,8 +42,10 @@ public final class ChatControllerInteraction { let openHashtag: (String?, String) -> Void let updateInputState: ((ChatTextInputState) -> ChatTextInputState) -> Void - public init(openMessage: @escaping (MessageId) -> Void, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, clickThroughMessage: @escaping () -> Void, toggleMessageSelection: @escaping (MessageId) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (TelegramMediaFile) -> Void, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, 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) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, clickThroughMessage: @escaping () -> Void, toggleMessageSelection: @escaping (MessageId) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (TelegramMediaFile) -> Void, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?, Bool) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, openInstantPage: @escaping (MessageId) -> Void, openHashtag: @escaping (String?, String) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void) { self.openMessage = openMessage + self.openSecretMessagePreview = openSecretMessagePreview + self.closeSecretMessagePreview = closeSecretMessagePreview self.openPeer = openPeer self.openPeerMention = openPeerMention self.openMessageContextMenu = openMessageContextMenu diff --git a/TelegramUI/ChatHistoryEntry.swift b/TelegramUI/ChatHistoryEntry.swift index aa9f572d21..22d73c4681 100644 --- a/TelegramUI/ChatHistoryEntry.swift +++ b/TelegramUI/ChatHistoryEntry.swift @@ -1,7 +1,7 @@ import Postbox import TelegramCore -enum ChatHistoryEntry: Identifiable, Comparable { +enum ChatHistoryEntry: Identifiable, Comparable, CustomStringConvertible { case HoleEntry(MessageHistoryHole) case MessageEntry(Message, Bool) case UnreadEntry(MessageIndex) @@ -32,6 +32,19 @@ enum ChatHistoryEntry: Identifiable, Comparable { return MessageIndex.absoluteLowerBound() } } + + var description: String { + switch self { + case let .HoleEntry(hole): + return "HoleEntry(\(hole))" + case let .MessageEntry(message, read): + return "MessageEntry(\(message), \(read))" + case let .UnreadEntry(index): + return "UnreadEntry(\(index))" + case .ChatInfoEntry: + return "ChatInfoEntry" + } + } } func ==(lhs: ChatHistoryEntry, rhs: ChatHistoryEntry) -> Bool { diff --git a/TelegramUI/ChatHistoryListNode.swift b/TelegramUI/ChatHistoryListNode.swift index 4c16bb67e1..e0cff3b0d1 100644 --- a/TelegramUI/ChatHistoryListNode.swift +++ b/TelegramUI/ChatHistoryListNode.swift @@ -305,25 +305,23 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { self.historyDisposable.set(appliedTransition.start()) - let previousMaxIncomingMessageIdByNamespace = Atomic<[MessageId.Namespace: MessageId]>(value: [:]) + let previousMaxIncomingMessageIndexByNamespace = Atomic<[MessageId.Namespace: MessageIndex]>(value: [:]) let readHistory = combineLatest(self.maxVisibleIncomingMessageIndex.get(), self.canReadHistory.get()) |> map { messageIndex, canRead in if canRead { var apply = false - let _ = previousMaxIncomingMessageIdByNamespace.modify { dict in + let _ = previousMaxIncomingMessageIndexByNamespace.modify { dict in let previousIndex = dict[messageIndex.id.namespace] - if previousIndex == nil || previousIndex!.id < messageIndex.id.id { + if previousIndex == nil || previousIndex! < messageIndex { apply = true var dict = dict - dict[messageIndex.id.namespace] = messageIndex.id + dict[messageIndex.id.namespace] = messageIndex return dict } return dict } if apply { - let _ = account.postbox.modify({ modifier in - modifier.applyInteractiveReadMaxId(messageIndex.id) - }).start() + applyMaxReadIndexInteractively(postbox: account.postbox, network: account.network, index: messageIndex).start() } } } diff --git a/TelegramUI/ChatHistoryNavigationButtonNode.swift b/TelegramUI/ChatHistoryNavigationButtonNode.swift index 573f7664c2..1b3caaa354 100644 --- a/TelegramUI/ChatHistoryNavigationButtonNode.swift +++ b/TelegramUI/ChatHistoryNavigationButtonNode.swift @@ -22,23 +22,49 @@ private func generateBackgroundImage() -> UIImage? { } private let backgroundImage = generateBackgroundImage() +private let badgeImage = generateStretchableFilledCircleImage(diameter: 18.0, color: UIColor(0x007ee5), backgroundColor: nil) +private let badgeFont = Font.regular(13.0) class ChatHistoryNavigationButtonNode: ASControlNode { private let imageNode: ASImageNode + private let badgeBackgroundNode: ASImageNode + private let badgeTextNode: ASTextNode var tapped: (() -> Void)? + var badge: String = "" { + didSet { + if self.badge != oldValue { + self.layoutBadge() + } + } + } + override init() { self.imageNode = ASImageNode() self.imageNode.displayWithoutProcessing = true self.imageNode.image = backgroundImage self.imageNode.isLayerBacked = true + self.badgeBackgroundNode = ASImageNode() + self.badgeBackgroundNode.isLayerBacked = true + self.badgeBackgroundNode.displayWithoutProcessing = true + self.badgeBackgroundNode.displaysAsynchronously = false + self.badgeBackgroundNode.image = badgeImage + + self.badgeTextNode = ASTextNode() + self.badgeTextNode.maximumNumberOfLines = 1 + self.badgeTextNode.isLayerBacked = true + self.badgeTextNode.displaysAsynchronously = false + super.init() self.addSubnode(self.imageNode) self.imageNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 38.0, height: 38.0)) + self.addSubnode(self.badgeBackgroundNode) + self.addSubnode(self.badgeTextNode) + self.frame = CGRect(origin: CGPoint(), size: CGSize(width: 38.0, height: 38.0)) self.addTarget(self, action: #selector(onTap), forControlEvents: .touchUpInside) @@ -49,4 +75,21 @@ class ChatHistoryNavigationButtonNode: ASControlNode { tapped() } } + + private func layoutBadge() { + if !self.badge.isEmpty { + self.badgeTextNode.attributedText = NSAttributedString(string: self.badge, font: badgeFont, textColor: .white) + self.badgeBackgroundNode.isHidden = false + self.badgeTextNode.isHidden = false + + let badgeSize = self.badgeTextNode.measure(CGSize(width: 200.0, height: 100.0)) + let backgroundSize = CGSize(width: max(18.0, badgeSize.width + 10.0 + 1.0), height: 18.0) + let backgroundFrame = CGRect(origin: CGPoint(x: floor((38.0 - backgroundSize.width) / 2.0), y: -6.0), size: backgroundSize) + self.badgeBackgroundNode.frame = backgroundFrame + self.badgeTextNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels(backgroundFrame.midX - badgeSize.width / 2.0), y: -5.0), size: badgeSize) + } else { + self.badgeBackgroundNode.isHidden = true + self.badgeTextNode.isHidden = true + } + } } diff --git a/TelegramUI/ChatHistoryViewForLocation.swift b/TelegramUI/ChatHistoryViewForLocation.swift index 88cf0044db..3634f394ea 100644 --- a/TelegramUI/ChatHistoryViewForLocation.swift +++ b/TelegramUI/ChatHistoryViewForLocation.swift @@ -21,7 +21,7 @@ func chatHistoryViewForLocation(_ location: ChatHistoryLocation, account: Accoun } else { var scrollPosition: ChatHistoryViewScrollPosition? - if let maxReadIndex = view.maxReadIndex { + if let maxReadIndex = view.maxReadIndex, tagMask == nil { let aroundIndex = maxReadIndex scrollPosition = .Unread(index: maxReadIndex) diff --git a/TelegramUI/ChatHoleItem.swift b/TelegramUI/ChatHoleItem.swift index cd8f82a9a3..4c134b451f 100644 --- a/TelegramUI/ChatHoleItem.swift +++ b/TelegramUI/ChatHoleItem.swift @@ -104,4 +104,12 @@ class ChatHoleItemNode: ListViewItemNode { return nil } } + + override public func animateAdded(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.5, removeOnCompletion: false) + } } diff --git a/TelegramUI/ChatInterfaceInputContexts.swift b/TelegramUI/ChatInterfaceInputContexts.swift index e29a0675b2..89a0fb5e07 100644 --- a/TelegramUI/ChatInterfaceInputContexts.swift +++ b/TelegramUI/ChatInterfaceInputContexts.swift @@ -167,6 +167,9 @@ func inputTextPanelStateForChatPresentationInterfaceState(_ chatPresentationInte } else { if chatPresentationInterfaceState.interfaceState.composeInputState.inputText.isEmpty { var accessoryItems: [ChatTextInputAccessoryItem] = [] + if let peer = chatPresentationInterfaceState.peer as? TelegramSecretChat { + accessoryItems.append(.messageAutoremoveTimeout(peer.messageAutoremoveTimeout)) + } accessoryItems.append(.stickers) if let message = chatPresentationInterfaceState.keyboardButtonsMessage, let _ = message.visibleButtonKeyboardMarkup { accessoryItems.append(.inputButtons) diff --git a/TelegramUI/ChatListController.swift b/TelegramUI/ChatListController.swift index d4b20f09ce..b4eeb08596 100644 --- a/TelegramUI/ChatListController.swift +++ b/TelegramUI/ChatListController.swift @@ -4,6 +4,21 @@ import SwiftSignalKit import Display import TelegramCore +private let composeButtonImage = generateImage(CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in + /* + + + + Created with Sketch. + + + + */ + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(0x007ee5).cgColor) + try? drawSvgPath(context, path: "M0,4 L15,4 L14,5 L1,5 L1,22 L18,22 L18,9 L19,8 L19,23 L0,23 L0,4 Z M18.5944456,1.70209754 L19.5995507,2.70718758 L10.0510517,12.255543 L9.54849908,13.7631781 L11.0561568,13.2606331 L20.6046559,3.71227763 L21.6097611,4.71736767 L11.5587094,14.7682681 L7.53828874,15.7733582 L9.04594649,11.250453 L18.5944456,1.70209754 Z M19.0969982,1.19955251 L20.0773504,0.21921503 C20.3690844,-0.0725145755 20.8398084,-0.0729335627 21.1298838,0.217137419 L23.0947435,2.18196761 C23.3833646,2.47058439 23.3838887,2.94326675 23.0926659,3.23448517 L22.1123136,4.21482265 L19.0969982,1.19955251 Z ") +}) + public class ChatListController: TelegramController { private let account: Account @@ -33,7 +48,7 @@ public class ChatListController: TelegramController { self.tabBarItem.selectedImage = UIImage(bundleImageName: "Chat List/Tabs/IconChatsSelected") self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", style: .plain, target: self, action: #selector(self.editPressed)) - //self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .Compose, target: self, action: Selector("composePressed")) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: composeButtonImage, style: .plain, target: self, action: #selector(self.composePressed)) self.scrollToTop = { [weak self] in if let strongSelf = self { @@ -119,7 +134,7 @@ public class ChatListController: TelegramController { if let strongSelf = self { let storedPeer = strongSelf.account.postbox.modify { modifier -> Void in if modifier.getPeer(peer.id) == nil { - modifier.updatePeers([peer], update: { previousPeer, updatedPeer in + updatePeers(modifier: modifier, peers: [peer], update: { previousPeer, updatedPeer in return updatedPeer }) } @@ -150,7 +165,17 @@ public class ChatListController: TelegramController { } @objc func editPressed() { - + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(self.donePressed)) + self.chatListDisplayNode.chatListNode.updateState { state in + return state.withUpdatedEditing(true) + } + } + + @objc func donePressed() { + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", style: .plain, target: self, action: #selector(self.editPressed)) + self.chatListDisplayNode.chatListNode.updateState { state in + return state.withUpdatedEditing(false).withUpdatedPeerIdWithRevealedOptions(nil) + } } private func activateSearch() { @@ -169,5 +194,9 @@ public class ChatListController: TelegramController { self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring)) } } + + @objc func composePressed() { + (self.navigationController as? NavigationController)?.pushViewController(ComposeController(account: self.account)) + } } diff --git a/TelegramUI/ChatListControllerNode.swift b/TelegramUI/ChatListControllerNode.swift index d3802de6b5..ef9570595f 100644 --- a/TelegramUI/ChatListControllerNode.swift +++ b/TelegramUI/ChatListControllerNode.swift @@ -54,7 +54,6 @@ class ChatListControllerNode: ASDisplayNode { } let listViewCurve: ListViewAnimationCurve - var speedFactor: CGFloat = 1.0 if curve == 7 { listViewCurve = .Spring(duration: duration) } else { diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index 6afe5aaab9..54316bf20c 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -8,35 +8,50 @@ import TelegramCore class ChatListItem: ListViewItem { let account: Account - let message: Message + let index: ChatListIndex + let message: Message? + let peer: RenderedPeer let combinedReadState: CombinedPeerReadState? let notificationSettings: PeerNotificationSettings? let embeddedState: PeerChatListEmbeddedInterfaceState? - let action: (Message) -> Void + let editing: Bool + let hasActiveRevealControls: Bool + let interaction: ChatListNodeInteraction let selectable: Bool = true let header: ListViewItemHeader? - init(account: Account, message: Message, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, embeddedState: PeerChatListEmbeddedInterfaceState?, header: ListViewItemHeader?, action: @escaping (Message) -> Void) { + init(account: Account, index: ChatListIndex, message: Message?, peer: RenderedPeer, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, embeddedState: PeerChatListEmbeddedInterfaceState?, editing: Bool, hasActiveRevealControls: Bool, header: ListViewItemHeader?, interaction: ChatListNodeInteraction) { self.account = account + self.index = index self.message = message + self.peer = peer self.combinedReadState = combinedReadState self.notificationSettings = notificationSettings self.embeddedState = embeddedState + self.editing = editing + self.hasActiveRevealControls = hasActiveRevealControls self.header = header - self.action = action + self.interaction = interaction } func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { let node = ChatListItemNode() - node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings, embeddedState: self.embeddedState) + node.setupItem(item: self) let (first, last, firstWithHeader) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) node.insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) - node.layoutForWidth(width, item: self, previousItem: previousItem, nextItem: nextItem) + + let (nodeLayout, apply) = node.asyncLayout()(self, width, first, last, firstWithHeader) + + node.insets = nodeLayout.insets + node.contentSize = nodeLayout.contentSize + completion(node, { - return (nil, {}) + return (nil, { + apply(false) + }) }) } } @@ -45,15 +60,19 @@ class ChatListItem: ListViewItem { assert(node is ChatListItemNode) if let node = node as? ChatListItemNode { Queue.mainQueue().async { - node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings, embeddedState: self.embeddedState) + node.setupItem(item: self) let layout = node.asyncLayout() async { let (first, last, firstWithHeader) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) + var animated = true + if case .None = animation { + animated = false + } - let (nodeLayout, apply) = layout(self.account, self, width, first, last, firstWithHeader) + let (nodeLayout, apply) = layout(self, width, first, last, firstWithHeader) Queue.mainQueue().async { - completion(nodeLayout, { [weak node] in - apply() + completion(nodeLayout, { + apply(animated) }) } } @@ -62,7 +81,11 @@ class ChatListItem: ListViewItem { } func selected(listView: ListView) { - self.action(self.message) + if let message = self.message { + self.interaction.messageSelected(message) + } else if let peer = self.peer.peers[self.peer.peerId] { + self.interaction.peerSelected(peer) + } } static func mergeType(item: ChatListItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool) { @@ -81,7 +104,7 @@ class ChatListItem: ListViewItem { first = true firstWithHeader = item.header != nil } - if let nextItem = nextItem { + if let _ = nextItem { } else { last = true } @@ -91,9 +114,47 @@ class ChatListItem: ListViewItem { private let titleFont = Font.medium(17.0) private let textFont = Font.regular(15.0) -private let dateFont = Font.regular(floorToScreenPixels(14.0)) +private let dateFont = Font.regular(14.0) private let badgeFont = Font.regular(14.0) +private let titleSecretColor = UIColor(0x00a629) + +private let pinIcon = UIImage(bundleImageName: "Chat List/RevealActionPinIcon")?.precomposed() +private let unpinIcon = UIImage(bundleImageName: "Chat List/RevealActionUnpinIcon")?.precomposed() +private let muteIcon = UIImage(bundleImageName: "Chat List/RevealActionMuteIcon")?.precomposed() +private let unmuteIcon = UIImage(bundleImageName: "Chat List/RevealActionUnmuteIcon")?.precomposed() +private let deleteIcon = UIImage(bundleImageName: "Chat List/RevealActionDeleteIcon")?.precomposed() + +private enum RevealOptionKey: Int32 { + case pin + case unpin + case mute + case unmute + case delete +} + +private let pinOption = ItemListRevealOption(key: RevealOptionKey.pin.rawValue, title: "Pin", icon: pinIcon, color: UIColor(0xbcbcc3)) +private let unpinOption = ItemListRevealOption(key: RevealOptionKey.unpin.rawValue, title: "Unpin", icon: unpinIcon, color: UIColor(0xbcbcc3)) +private let muteOption = ItemListRevealOption(key: RevealOptionKey.mute.rawValue, title: "Mute", icon: muteIcon, color: UIColor(0xaaaab3)) +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 func revealOptions(isPinned: Bool, isMuted: Bool) -> [ItemListRevealOption] { + var options: [ItemListRevealOption] = [] + if isPinned { + options.append(unpinOption) + } else { + options.append(pinOption) + } + if isMuted { + options.append(unmuteOption) + } else { + options.append(muteOption) + } + options.append(deleteOption) + return options +} + private func generateStatusCheckImage(single: Bool) -> UIImage? { return generateImage(CGSize(width: single ? 13.0 : 18.0, height: 13.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) @@ -138,12 +199,8 @@ private let peerMutedIcon = UIImage(bundleImageName: "Chat List/PeerMutedIcon")? private let separatorHeight = 1.0 / UIScreen.main.scale -class ChatListItemNode: ListViewItemNode { - var account: Account? - var message: Message? - var combinedReadState: CombinedPeerReadState? - var notificationSettings: PeerNotificationSettings? - var embeddedState: PeerChatListEmbeddedInterfaceState? +class ChatListItemNode: ItemListRevealOptionsItemNode { + var item: ChatListItem? private let backgroundNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode @@ -159,8 +216,18 @@ class ChatListItemNode: ListViewItemNode { let badgeTextNode: TextNode let mutedIconNode: ASImageNode + var editableControlNode: ItemListEditableControlNode? + var layoutParams: (ChatListItem, first: Bool, last: Bool, firstWithHeader: Bool)? + override var canBeSelected: Bool { + if self.editableControlNode != nil { + return false + } else { + return super.canBeSelected + } + } + required init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true @@ -218,7 +285,7 @@ class ChatListItemNode: ListViewItemNode { self.separatorNode.backgroundColor = UIColor(0xc8c7cc) self.separatorNode.isLayerBacked = true - super.init(layerBacked: false, dynamicBounce: false) + super.init(layerBacked: false, dynamicBounce: false, rotated: false) self.addSubnode(self.backgroundNode) self.addSubnode(self.separatorNode) @@ -234,24 +301,28 @@ class ChatListItemNode: ListViewItemNode { self.contentNode.addSubnode(self.mutedIconNode) } - func setupItem(account: Account, message: Message, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, embeddedState: PeerChatListEmbeddedInterfaceState?) { - self.account = account - self.message = message - self.combinedReadState = combinedReadState - self.notificationSettings = notificationSettings - self.embeddedState = embeddedState + func setupItem(item: ChatListItem) { + self.item = item - let peer = message.peers[message.id.peerId] - if let peer = peer { - self.avatarNode.setPeer(account: account, peer: peer) + if let message = item.message { + let peer = messageMainPeer(message) + if let peer = peer { + self.avatarNode.setPeer(account: item.account, peer: peer) + } + } else { + if let peer = item.peer.chatMainPeer { + self.avatarNode.setPeer(account: item.account, peer: peer) + } } } override func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { let layout = self.asyncLayout() let (first, last, firstWithHeader) = ChatListItem.mergeType(item: item as! ChatListItem, previousItem: previousItem, nextItem: nextItem) - let (_, apply) = layout(self.account, item as! ChatListItem, width, first, last, firstWithHeader) - apply() + let (nodeLayout, apply) = layout(item as! ChatListItem, width, first, last, firstWithHeader) + apply(false) + self.contentSize = nodeLayout.contentSize + self.insets = nodeLayout.insets } class func insets(first: Bool, last: Bool, firstWithHeader: Bool) -> UIEdgeInsets { @@ -294,18 +365,20 @@ class ChatListItemNode: ListViewItemNode { } } - func asyncLayout() -> (_ account: Account?, _ item: ChatListItem, _ width: CGFloat, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ChatListItem, _ width: CGFloat, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, (Bool) -> Void) { let dateLayout = TextNode.asyncLayout(self.dateNode) let textLayout = TextNode.asyncLayout(self.textNode) let titleLayout = TextNode.asyncLayout(self.titleNode) let badgeTextLayout = TextNode.asyncLayout(self.badgeTextNode) + let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode) - let message = self.message - let combinedReadState = self.combinedReadState - let notificationSettings = self.notificationSettings - let embeddedState = self.embeddedState - - return { account, item, width, first, last, firstWithHeader in + return { item, width, first, last, firstWithHeader in + let account = item.account + let message = item.message + let combinedReadState = item.combinedReadState + let notificationSettings = item.notificationSettings + let embeddedState = item.embeddedState + var textAttributedString: NSAttributedString? var dateAttributedString: NSAttributedString? var titleAttributedString: NSAttributedString? @@ -315,29 +388,52 @@ class ChatListItemNode: ListViewItemNode { var currentBadgeBackgroundImage: UIImage? var currentMutedIconImage: UIImage? - if let message = message { - let peer = message.peers[message.id.peerId] + var editableControlSizeAndApply: (CGSize, () -> ItemListEditableControlNode)? + + let editingOffset: CGFloat + if item.editing { + let sizeAndApply = editableControlLayout(68.0) + editableControlSizeAndApply = sizeAndApply + editingOffset = sizeAndApply.0.width + } else { + editingOffset = 0.0 + } + + if true { + let peer: Peer? - var messageText: NSString = message.text as NSString - if message.text.isEmpty { - for media in message.media { - switch media { - case _ as TelegramMediaImage: - messageText = "Photo" - case let fileMedia as TelegramMediaFile: - if fileMedia.isSticker { - messageText = "Sticker" - } else { - messageText = "File" - } - case _ as TelegramMediaMap: - messageText = "Map" - case _ as TelegramMediaContact: - messageText = "Contact" - default: - break + var messageText: NSString + if let message = message { + if let messageMain = messageMainPeer(message) { + peer = messageMain + } else { + peer = item.peer.chatMainPeer + } + + messageText = message.text as NSString + if message.text.isEmpty { + for media in message.media { + switch media { + case _ as TelegramMediaImage: + messageText = "Photo" + case let fileMedia as TelegramMediaFile: + if fileMedia.isSticker { + messageText = "Sticker" + } else { + messageText = "File" + } + case _ as TelegramMediaMap: + messageText = "Map" + case _ as TelegramMediaContact: + messageText = "Contact" + default: + break + } } } + } else { + peer = item.peer.chatMainPeer + messageText = "" } let attributedText: NSAttributedString @@ -346,24 +442,28 @@ class ChatListItemNode: ListViewItemNode { mutableAttributedText.append(NSAttributedString(string: "Draft: ", font: textFont, textColor: UIColor(0xdd4b39))) mutableAttributedText.append(NSAttributedString(string: embeddedState.text, font: textFont, textColor: UIColor.black)) attributedText = mutableAttributedText; - } else if let author = message.author as? TelegramUser, let peer = peer, peer as? TelegramUser == nil { - let peerText: NSString = (author.id == account?.peerId ? "You: " : author.compactDisplayTitle + ": ") as NSString - - 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; + } 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 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; + } } else { attributedText = NSAttributedString(string: messageText as String, font: textFont, textColor: UIColor(0x8e8e93)) } if let displayTitle = peer?.displayTitle { - titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: UIColor.black) + titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: item.index.messageIndex.id.peerId.namespace == Namespaces.Peer.SecretChat ? titleSecretColor : UIColor.black) } textAttributedString = attributedText - var t = Int(message.timestamp) + var t = Int(item.index.messageIndex.timestamp) var timeinfo = tm() localtime_r(&t, &timeinfo) @@ -371,9 +471,9 @@ class ChatListItemNode: ListViewItemNode { dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: UIColor(0x8e8e93)) - if message.author?.id == account?.peerId { - if !message.flags.contains(.Unsent) && !message.flags.contains(.Failed) { - if let combinedReadState = combinedReadState, combinedReadState.isOutgoingMessageIdRead(message.id) { + if let message = message, message.author?.id == account.peerId { + if !message.flags.isSending { + if let combinedReadState = combinedReadState, combinedReadState.isOutgoingMessageIndexRead(MessageIndex(message)) { statusImage = statusDoubleCheckImage } else { statusImage = statusSingleCheckImage @@ -411,7 +511,7 @@ class ChatListItemNode: ListViewItemNode { muteWidth = currentMutedIconImage.size.width + 4.0 } - let contentRect = CGRect(origin: CGPoint(x: 2.0, y: 12.0), size: CGSize(width: width - 78.0 - 10.0 - 1.0, height: 68.0 - 12.0 - 9.0)) + let contentRect = 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 (dateLayout, dateApply) = dateLayout(dateAttributedString, nil, 1, .end, CGSize(width: contentRect.width, height: CGFloat.greatestFiniteMagnitude), nil) @@ -432,12 +532,54 @@ class ChatListItemNode: ListViewItemNode { let insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 68.0), insets: insets) - return (layout, { [weak self] in + let peerRevealOptions = revealOptions(isPinned: item.index.pinningIndex != nil, isMuted: currentMutedIconImage != nil) + + return (layout, { [weak self] animated in if let strongSelf = self { strongSelf.layoutParams = (item, first, last, firstWithHeader) - strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 10.0, y: 4.0), size: CGSize(width: 60.0, height: 60.0)) - strongSelf.contentNode.frame = CGRect(origin: CGPoint(x: 78.0, y: 0.0), size: CGSize(width: width - 78.0, height: 60.0)) + let revealOffset = strongSelf.revealOffset + + let transition: ContainedViewLayoutTransition + if animated { + transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + } else { + transition = .immediate + } + + var crossfadeContent = false + if let editableControlSizeAndApply = editableControlSizeAndApply { + if strongSelf.editableControlNode == nil { + crossfadeContent = true + let editableControlNode = editableControlSizeAndApply.1() + editableControlNode.tapped = { + if let strongSelf = self { + strongSelf.setRevealOptionsOpened(true, animated: true) + strongSelf.revealOptionsInteractivelyOpened() + } + } + strongSelf.editableControlNode = editableControlNode + strongSelf.addSubnode(editableControlNode) + let editableControlFrame = CGRect(origin: CGPoint(x: revealOffset, y: 0.0), size: editableControlSizeAndApply.0) + editableControlNode.frame = editableControlFrame + transition.animatePosition(node: editableControlNode, from: CGPoint(x: editableControlFrame.midX - editableControlFrame.size.width, y: editableControlFrame.midY)) + editableControlNode.alpha = 0.0 + transition.updateAlpha(node: editableControlNode, alpha: 1.0) + } + } else if let editableControlNode = strongSelf.editableControlNode { + crossfadeContent = true + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = -editableControlFrame.size.width + strongSelf.editableControlNode = nil + transition.updateAlpha(node: editableControlNode, alpha: 0.0) + transition.updateFrame(node: editableControlNode, frame: editableControlFrame, completion: { [weak editableControlNode] _ in + editableControlNode?.removeFromSupernode() + }) + } + + 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))) + let previousContentNodeFrame = strongSelf.contentNode.frame + transition.updateFrame(node: strongSelf.contentNode, frame: CGRect(origin: CGPoint(x: editingOffset + 78.0 + revealOffset, y: 0.0), size: CGSize(width: width - 78.0, height: 60.0))) let _ = dateApply() let _ = textApply() @@ -491,18 +633,31 @@ class ChatListItemNode: ListViewItemNode { strongSelf.textNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.maxY - textLayout.size.height - 1.0), size: textLayout.size) - strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: 78.0 + contentRect.origin.x, y: 68.0 - separatorHeight), size: CGSize(width: width - 78.0, height: separatorHeight)) - - strongSelf.contentSize = layout.contentSize - strongSelf.insets = layout.insets + transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: editingOffset + 78.0 + contentRect.origin.x, y: 68.0 - separatorHeight), size: CGSize(width: width - 78.0 - editingOffset, height: separatorHeight))) strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) let topNegativeInset: CGFloat = 0.0 strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -separatorHeight - topNegativeInset), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height + separatorHeight + topNegativeInset)) + if crossfadeContent && animated { + if let contents = strongSelf.contentNode.contents { + let tempNode = ASDisplayNode() + tempNode.isLayerBacked = true + tempNode.contents = contents + tempNode.frame = previousContentNodeFrame + strongSelf.insertSubnode(tempNode, aboveSubnode: strongSelf.contentNode) + transition.updateFrame(node: tempNode, frame: strongSelf.contentNode.frame) + transition.updateAlpha(node: tempNode, alpha: 0.0, completion: { [weak tempNode] _ in + tempNode?.removeFromSupernode() + }) + } + } if updateContentNode { strongSelf.contentNode.setNeedsDisplay() } + + strongSelf.setRevealOptions(peerRevealOptions) + strongSelf.setRevealOptionsOpened(item.hasActiveRevealControls, animated: animated) } }) } @@ -523,4 +678,61 @@ class ChatListItemNode: ListViewItemNode { return nil } } + + override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + super.updateRevealOffset(offset: offset, transition: transition) + + if let _ = self.item { + let editingOffset: CGFloat + if let editableControlNode = self.editableControlNode { + editingOffset = editableControlNode.bounds.size.width + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = offset + transition.updateFrame(node: editableControlNode, frame: editableControlFrame) + } else { + editingOffset = 0.0 + } + + var avatarFrame = self.avatarNode.frame + avatarFrame.origin.x = editingOffset + 10.0 + offset + transition.updateFrame(node: self.avatarNode, frame: avatarFrame) + + var contentFrame = self.contentNode.frame + contentFrame.origin.x = editingOffset + 78.0 + offset + transition.updateFrame(node: self.contentNode, frame: contentFrame) + } + } + + override func revealOptionsInteractivelyOpened() { + if let item = self.item { + item.interaction.setPeerIdWithRevealedOptions(item.index.messageIndex.id.peerId, nil) + } + } + + override func revealOptionsInteractivelyClosed() { + if let item = self.item { + item.interaction.setPeerIdWithRevealedOptions(nil, item.index.messageIndex.id.peerId) + } + } + + override func revealOptionSelected(_ option: ItemListRevealOption) { + self.setRevealOptionsOpened(false, animated: true) + self.revealOptionsInteractivelyClosed() + if let item = self.item { + switch option.key { + case RevealOptionKey.pin.rawValue: + break + case RevealOptionKey.unpin.rawValue: + break + case RevealOptionKey.mute.rawValue: + break + case RevealOptionKey.unmute.rawValue: + break + case RevealOptionKey.delete.rawValue: + item.interaction + default: + break + } + } + } } diff --git a/TelegramUI/ChatListNode.swift b/TelegramUI/ChatListNode.swift index 4e606e0d0d..4e7ad3c33d 100644 --- a/TelegramUI/ChatListNode.swift +++ b/TelegramUI/ChatListNode.swift @@ -22,11 +22,44 @@ struct ChatListNodeListViewTransition { final class ChatListNodeInteraction { let activateSearch: () -> Void - let peerSelected: (PeerId) -> Void + let peerSelected: (Peer) -> Void + let messageSelected: (Message) -> Void + let setPeerIdWithRevealedOptions: (PeerId?, PeerId?) -> Void + let setPeerPinned: (PeerId, Bool) -> Void + let setPeerMuted: (PeerId, Bool) -> Void + let deletePeer: (PeerId) -> Void - init(activateSearch: @escaping () -> Void, peerSelected: @escaping (PeerId) -> Void) { + init(activateSearch: @escaping () -> Void, peerSelected: @escaping (Peer) -> Void, messageSelected: @escaping (Message) -> Void, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, setPeerPinned: @escaping (PeerId, Bool) -> Void, setPeerMuted: @escaping (PeerId, Bool) -> Void, deletePeer: @escaping (PeerId) -> Void) { self.activateSearch = activateSearch self.peerSelected = peerSelected + self.messageSelected = messageSelected + self.setPeerIdWithRevealedOptions = setPeerIdWithRevealedOptions + self.setPeerPinned = setPeerPinned + self.setPeerMuted = setPeerMuted + self.deletePeer = deletePeer + } +} + +struct ChatListNodeState: Equatable { + let editing: Bool + let peerIdWithRevealedOptions: PeerId? + + func withUpdatedEditing(_ editing: Bool) -> ChatListNodeState { + return ChatListNodeState(editing: editing, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions) + } + + func withUpdatedPeerIdWithRevealedOptions(_ peerIdWithRevealedOptions: PeerId?) -> ChatListNodeState { + return ChatListNodeState(editing: self.editing, peerIdWithRevealedOptions: peerIdWithRevealedOptions) + } + + static func ==(lhs: ChatListNodeState, rhs: ChatListNodeState) -> Bool { + if lhs.editing != rhs.editing { + return false + } + if lhs.peerIdWithRevealedOptions != rhs.peerIdWithRevealedOptions { + return false + } + return true } } @@ -37,15 +70,21 @@ private func mappedInsertEntries(account: Account, nodeInteraction: ChatListNode return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListSearchItem(placeholder: "Search for messages or users", activate: { nodeInteraction.activateSearch() }), directionHint: entry.directionHint) - case let .MessageEntry(_, message, combinedReadState, notificationSettings, embeddedState): + case let .PeerEntry(index, message, combinedReadState, notificationSettings, embeddedState, peer, editing, hasActiveRevealControls): switch mode { case .chatList: - return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, header: nil, action: { _ in - nodeInteraction.peerSelected(message.id.peerId) - }), directionHint: entry.directionHint) + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, index: index, message: message, peer: peer, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, editing: editing, hasActiveRevealControls: hasActiveRevealControls, header: nil, interaction: nodeInteraction), directionHint: entry.directionHint) case .peers: - return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: message.peers[message.id.peerId], status: .none, index: nil, header: nil, action: { _ in - nodeInteraction.peerSelected(message.id.peerId) + var peer: Peer? + var chatPeer: Peer? + if let message = message { + peer = messageMainPeer(message) + chatPeer = message.peers[message.id.peerId] + } + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: peer, chatPeer: chatPeer, status: .none, selection: .none, index: nil, header: nil, action: { _ in + if let chatPeer = chatPeer { + nodeInteraction.peerSelected(chatPeer) + } }), directionHint: entry.directionHint) } case .HoleEntry: @@ -63,15 +102,21 @@ private func mappedUpdateEntries(account: Account, nodeInteraction: ChatListNode return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListSearchItem(placeholder: "Search for messages or users", activate: { nodeInteraction.activateSearch() }), directionHint: entry.directionHint) - case let .MessageEntry(_, message, combinedReadState, notificationSettings, embeddedState): + case let .PeerEntry(index, message, combinedReadState, notificationSettings, embeddedState, peer, editing, hasActiveRevealControls): switch mode { case .chatList: - return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, header: nil, action: { _ in - nodeInteraction.peerSelected(message.id.peerId) - }), directionHint: entry.directionHint) + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, index: index, message: message, peer: peer, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, editing: editing, hasActiveRevealControls: hasActiveRevealControls, header: nil, interaction: nodeInteraction), directionHint: entry.directionHint) case .peers: - return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: message.peers[message.id.peerId], status: .none, index: nil, header: nil, action: { _ in - nodeInteraction.peerSelected(message.id.peerId) + var peer: Peer? + var chatPeer: Peer? + if let message = message { + peer = messageMainPeer(message) + chatPeer = message.peers[message.id.peerId] + } + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: peer, chatPeer: chatPeer, status: .none, selection: .none, index: nil, header: nil, action: { _ in + if let chatPeer = chatPeer { + nodeInteraction.peerSelected(chatPeer) + } }), directionHint: entry.directionHint) } case .HoleEntry: @@ -110,6 +155,9 @@ final class ChatListNode: ListView { private var dequeuedInitialTransitionOnLayout = false private var enqueuedTransition: (ChatListNodeListViewTransition, () -> Void)? + private var currentState = ChatListNodeState(editing: false, peerIdWithRevealedOptions: nil) + private let statePromise = ValuePromise(ChatListNodeState(editing: false, peerIdWithRevealedOptions: nil), ignoreRepeated: true) + private var currentLocation: ChatListNodeLocation? private let chatListLocation = ValuePromise() private let chatListDisposable = MetaDisposable() @@ -121,10 +169,28 @@ final class ChatListNode: ListView { if let strongSelf = self, let activateSearch = strongSelf.activateSearch { activateSearch() } - }, peerSelected: { [weak self] peerId in + }, peerSelected: { [weak self] peer in if let strongSelf = self, let peerSelected = strongSelf.peerSelected { - peerSelected(peerId) + peerSelected(peer.id) } + }, messageSelected: { [weak self] message in + if let strongSelf = self, let peerSelected = strongSelf.peerSelected { + peerSelected(message.id.peerId) + } + }, setPeerIdWithRevealedOptions: { [weak self] peerId, fromPeerId in + if let strongSelf = self { + strongSelf.updateState { state in + if (peerId == nil && fromPeerId == state.peerIdWithRevealedOptions) || (peerId != nil && fromPeerId == nil) { + return state.withUpdatedPeerIdWithRevealedOptions(peerId) + } else { + return state + } + } + } + }, setPeerPinned: { _ in + }, setPeerMuted: { _ in + }, deletePeer: { peerId in + let _ = removePeerChat(postbox: account.postbox, peerId: peerId).start() }) let viewProcessingQueue = self.viewProcessingQueue @@ -137,8 +203,8 @@ final class ChatListNode: ListView { let previousView = Atomic(value: nil) - let chatListNodeViewTransition = chastListViewUpdate |> mapToQueue { [weak self] update -> Signal in - let processedView = ChatListNodeView(originalView: update.view, filteredEntries: chatListNodeEntriesForView(update.view)) + let chatListNodeViewTransition = combineLatest(chastListViewUpdate, self.statePromise.get()) |> mapToQueue { (update, state) -> Signal in + let processedView = ChatListNodeView(originalView: update.view, filteredEntries: chatListNodeEntriesForView(update.view, state: state)) let previous = previousView.swap(processedView) let reason: ChatListNodeViewTransitionReason @@ -213,6 +279,14 @@ final class ChatListNode: ListView { self.chatListDisposable.dispose() } + func updateState(_ f: (ChatListNodeState) -> ChatListNodeState) { + let state = f(self.currentState) + if state != self.currentState { + self.currentState = state + self.statePromise.set(state) + } + } + private func enqueueTransition(_ transition: ChatListNodeListViewTransition) -> Signal { return Signal { [weak self] subscriber in if let strongSelf = self { @@ -284,7 +358,8 @@ final class ChatListNode: ListView { if let view = self.chatListView?.originalView, view.laterIndex == nil { self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Default, directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } else { - let location: ChatListNodeLocation = .scroll(index: MessageIndex.absoluteUpperBound(), sourceIndex: MessageIndex.absoluteLowerBound(), scrollPosition: .Top, animated: true) + let location: ChatListNodeLocation = .scroll(index: ChatListIndex.absoluteUpperBound, sourceIndex: ChatListIndex.absoluteLowerBound + , scrollPosition: .Top, animated: true) self.currentLocation = location self.chatListLocation.set(location) } diff --git a/TelegramUI/ChatListNodeEntries.swift b/TelegramUI/ChatListNodeEntries.swift index cb7cb7641a..41759d0975 100644 --- a/TelegramUI/ChatListNodeEntries.swift +++ b/TelegramUI/ChatListNodeEntries.swift @@ -62,18 +62,18 @@ enum ChatListNodeEntryId: Hashable, CustomStringConvertible { enum ChatListNodeEntry: Comparable, Identifiable { case SearchEntry - case MessageEntry(MessageIndex, Message, CombinedPeerReadState?, PeerNotificationSettings?, PeerChatListEmbeddedInterfaceState?) + case PeerEntry(index: ChatListIndex, message: Message?, readState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, embeddedInterfaceState: PeerChatListEmbeddedInterfaceState?, peer: RenderedPeer, editing: Bool, hasActiveRevealControls: Bool) case HoleEntry(ChatListHole) - case Nothing(MessageIndex) + case Nothing(ChatListIndex) - var index: MessageIndex { + var index: ChatListIndex { switch self { case .SearchEntry: - return MessageIndex.absoluteUpperBound() - case let .MessageEntry(index, _, _, _, _): + return ChatListIndex.absoluteUpperBound + case let .PeerEntry(index, _, _, _, _, _, _, _): return index case let .HoleEntry(hole): - return hole.index + return ChatListIndex(pinningIndex: nil, messageIndex: hole.index) case let .Nothing(index): return index } @@ -83,12 +83,12 @@ enum ChatListNodeEntry: Comparable, Identifiable { switch self { case .SearchEntry: return .Search - case let .MessageEntry(index, _, _, _, _): - return .PeerId(index.id.peerId.toInt64()) + case let .PeerEntry(index, _, _, _, _, _, _, _): + return .PeerId(index.messageIndex.id.peerId.toInt64()) case let .HoleEntry(hole): return .Hole(Int64(hole.index.id.id)) case let .Nothing(index): - return .PeerId(index.id.peerId.toInt64()) + return .PeerId(index.messageIndex.id.peerId.toInt64()) } } @@ -105,16 +105,16 @@ enum ChatListNodeEntry: Comparable, Identifiable { default: return false } - case let .MessageEntry(lhsIndex, lhsMessage, lhsUnreadCount, lhsNotificationSettings, lhsEmbeddedState): + case let .PeerEntry(lhsIndex, lhsMessage, lhsUnreadCount, lhsNotificationSettings, lhsEmbeddedState, lhsPeer, lhsEditing, lhsHasRevealControls): switch rhs { - case let .MessageEntry(rhsIndex, rhsMessage, rhsUnreadCount, rhsNotificationSettings, rhsEmbeddedState): + case let .PeerEntry(rhsIndex, rhsMessage, rhsUnreadCount, rhsNotificationSettings, rhsEmbeddedState, rhsPeer, rhsEditing, rhsHasRevealControls): if lhsIndex != rhsIndex { return false } - if lhsMessage.stableVersion != rhsMessage.stableVersion { + if lhsMessage?.stableVersion != rhsMessage?.stableVersion { return false } - if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags || lhsUnreadCount != rhsUnreadCount { + if lhsMessage?.id != rhsMessage?.id || lhsMessage?.flags != rhsMessage?.flags || lhsUnreadCount != rhsUnreadCount { return false } if let lhsNotificationSettings = lhsNotificationSettings, let rhsNotificationSettings = rhsNotificationSettings { @@ -131,6 +131,15 @@ enum ChatListNodeEntry: Comparable, Identifiable { } else if (lhsEmbeddedState != nil) != (rhsEmbeddedState != nil) { return false } + if lhsEditing != rhsEditing { + return false + } + if lhsHasRevealControls != rhsHasRevealControls { + return false + } + if lhsPeer != rhsPeer { + return false + } return true default: break @@ -154,16 +163,14 @@ enum ChatListNodeEntry: Comparable, Identifiable { } } -func chatListNodeEntriesForView(_ view: ChatListView) -> [ChatListNodeEntry] { +func chatListNodeEntriesForView(_ view: ChatListView, state: ChatListNodeState) -> [ChatListNodeEntry] { var result: [ChatListNodeEntry] = [] for entry in view.entries { switch entry { - case let .MessageEntry(index, message, combinedReadState, notificationSettings, embeddedState): - result.append(.MessageEntry(index, message, combinedReadState, notificationSettings, embeddedState)) + case let .MessageEntry(index, message, combinedReadState, notificationSettings, embeddedState, peer): + result.append(.PeerEntry(index: index, message: message, readState: combinedReadState, notificationSettings: notificationSettings, embeddedInterfaceState: embeddedState, peer: peer, editing: state.editing, hasActiveRevealControls: index.messageIndex.id.peerId == state.peerIdWithRevealedOptions)) case let .HoleEntry(hole): result.append(.HoleEntry(hole)) - case let .Nothing(index): - result.append(.Nothing(index)) } } if view.laterIndex == nil { diff --git a/TelegramUI/ChatListNodeLocation.swift b/TelegramUI/ChatListNodeLocation.swift index 06e99a89d0..e4f5dabb94 100644 --- a/TelegramUI/ChatListNodeLocation.swift +++ b/TelegramUI/ChatListNodeLocation.swift @@ -6,8 +6,8 @@ import Display enum ChatListNodeLocation: Equatable { case initial(count: Int) - case navigation(index: MessageIndex) - case scroll(index: MessageIndex, sourceIndex: MessageIndex, scrollPosition: ListViewScrollPosition, animated: Bool) + case navigation(index: ChatListIndex) + case scroll(index: ChatListIndex, sourceIndex: ChatListIndex, scrollPosition: ListViewScrollPosition, animated: Bool) static func ==(lhs: ChatListNodeLocation, rhs: ChatListNodeLocation) -> Bool { switch lhs { diff --git a/TelegramUI/ChatListSearchContainerNode.swift b/TelegramUI/ChatListSearchContainerNode.swift index b8ab409c84..7e5aa61340 100644 --- a/TelegramUI/ChatListSearchContainerNode.swift +++ b/TelegramUI/ChatListSearchContainerNode.swift @@ -93,17 +93,17 @@ enum ChatListSearchEntry: Comparable, Identifiable { static func <(lhs: ChatListSearchEntry, rhs: ChatListSearchEntry) -> Bool { switch lhs { - case let .localPeer(lhsPeer, lhsIndex): - if case let .localPeer(rhsPeer, rhsIndex) = rhs { + case let .localPeer(_, lhsIndex): + if case let .localPeer(_, rhsIndex) = rhs { return lhsIndex < rhsIndex } else { return true } - case let .globalPeer(lhsPeer, lhsIndex): + case let .globalPeer(_, lhsIndex): switch rhs { case .localPeer: return false - case let .globalPeer(rhsPeer, rhsIndex): + case let .globalPeer(_, rhsIndex): return lhsIndex < rhsIndex case .message: return true @@ -117,20 +117,18 @@ enum ChatListSearchEntry: Comparable, Identifiable { } } - func item(account: Account, enableHeaders: Bool, openPeer: @escaping (Peer) -> Void, openMessage: @escaping (Message) -> Void) -> ListViewItem { + func item(account: Account, enableHeaders: Bool, interaction: ChatListNodeInteraction) -> ListViewItem { switch self { case let .localPeer(peer, _): - return ContactsPeerItem(account: account, peer: peer, status: .none, index: nil, header: ChatListSearchItemHeader(type: .localPeers), action: { _ in - openPeer(peer) + return ContactsPeerItem(account: account, peer: peer, chatPeer: peer, status: .none, selection: .none, index: nil, header: ChatListSearchItemHeader(type: .localPeers), action: { _ in + interaction.peerSelected(peer) }) case let .globalPeer(peer, _): - return ContactsPeerItem(account: account, peer: peer, status: .addressName, index: nil, header: ChatListSearchItemHeader(type: .globalPeers), action: { _ in - openPeer(peer) + return ContactsPeerItem(account: account, peer: peer, chatPeer: peer, status: .addressName, selection: .none, index: nil, header: ChatListSearchItemHeader(type: .globalPeers), action: { _ in + interaction.peerSelected(peer) }) case let .message(message): - return ChatListItem(account: account, message: message, combinedReadState: nil, notificationSettings: nil, embeddedState: nil, header: enableHeaders ? ChatListSearchItemHeader(type: .messages) : nil, action: { _ in - openMessage(message) - }) + return ChatListItem(account: account, index: ChatListIndex(pinningIndex: nil, messageIndex: MessageIndex(message)), message: message, peer: RenderedPeer(message: message), combinedReadState: nil, notificationSettings: nil, embeddedState: nil, editing: false, hasActiveRevealControls: false, header: enableHeaders ? ChatListSearchItemHeader(type: .messages) : nil, interaction: interaction) } } } @@ -142,12 +140,12 @@ struct ChatListSearchContainerTransition { let displayingResults: Bool } -func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], displayingResults: Bool, account: Account, enableHeaders: Bool, openPeer: @escaping (Peer) -> Void, openMessage: @escaping (Message) -> Void) -> ChatListSearchContainerTransition { +func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], displayingResults: Bool, account: Account, enableHeaders: Bool, interaction: ChatListNodeInteraction) -> ChatListSearchContainerTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, enableHeaders: enableHeaders, openPeer: openPeer, openMessage: openMessage), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, enableHeaders: enableHeaders, openPeer: openPeer, openMessage: openMessage), directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, enableHeaders: enableHeaders, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, enableHeaders: enableHeaders, interaction: interaction), directionHint: nil) } return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults) } @@ -222,7 +220,21 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { } let previousSearchItems = Atomic<[ChatListSearchEntry]?>(value: nil) - let processingQueue = Queue() + + let interaction = ChatListNodeInteraction(activateSearch: { + }, peerSelected: { [weak self] peer in + openPeer(peer) + self?.listNode.clearHighlightAnimated(true) + }, messageSelected: { [weak self] message in + if let peer = message.peers[message.id.peerId] { + openMessage(peer, message.id) + } + self?.listNode.clearHighlightAnimated(true) + }, setPeerIdWithRevealedOptions: { _ in + }, setPeerPinned: { _ in + }, setPeerMuted: { _ in + }, deletePeer: { _ in + }) self.searchDisposable.set((foundItems |> deliverOnMainQueue).start(next: { [weak self] entries in @@ -230,15 +242,7 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { let previousEntries = previousSearchItems.swap(entries) let firstTime = previousEntries == nil - let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries ?? [], displayingResults: entries != nil, account: account, enableHeaders: true, openPeer: { peer in - openPeer(peer) - self?.listNode.clearHighlightAnimated(true) - }, openMessage: { message in - if let peer = message.peers[message.id.peerId] { - openMessage(peer, message.id) - } - self?.listNode.clearHighlightAnimated(true) - }) + let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries ?? [], displayingResults: entries != nil, account: account, enableHeaders: true, interaction: interaction) strongSelf.enqueueTransition(transition, firstTime: firstTime) } })) diff --git a/TelegramUI/ChatListViewTransition.swift b/TelegramUI/ChatListViewTransition.swift index 8ec669aae4..97d1a243dd 100644 --- a/TelegramUI/ChatListViewTransition.swift +++ b/TelegramUI/ChatListViewTransition.swift @@ -41,7 +41,7 @@ struct ChatListNodeViewTransition { } enum ChatListNodeViewScrollPosition { - case index(index: MessageIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool) + case index(index: ChatListIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool) } func preparedChatListNodeViewTransition(from fromView: ChatListNodeView?, to toView: ChatListNodeView, reason: ChatListNodeViewTransitionReason, account: Account, scrollPosition: ChatListNodeViewScrollPosition?) -> Signal { @@ -87,10 +87,10 @@ func preparedChatListNodeViewTransition(from fromView: ChatListNodeView?, to toV if let (_, removeDirection) = removeHoleDirections.first { switch removeDirection { case .LowerToUpper: - var holeIndex: MessageIndex? + var holeIndex: ChatListIndex? for (index, _) in filledHoleDirections { - if holeIndex == nil || index < holeIndex! { - holeIndex = index + if holeIndex == nil || index < holeIndex!.messageIndex { + holeIndex = ChatListIndex(pinningIndex: nil, messageIndex: index) } } diff --git a/TelegramUI/ChatMessageActionItemNode.swift b/TelegramUI/ChatMessageActionItemNode.swift index 28e4e18442..929aa75cfa 100644 --- a/TelegramUI/ChatMessageActionItemNode.swift +++ b/TelegramUI/ChatMessageActionItemNode.swift @@ -15,6 +15,29 @@ private func backgroundImage(color: UIColor) -> UIImage? { private let titleFont = UIFont.systemFont(ofSize: 13.0) +private let timeoutValues: [(Int32, String)] = [ + (1, "1 second"), + (2, "2 seconds"), + (3, "3 seconds"), + (4, "4 seconds"), + (5, "5 seconds"), + (6, "6 seconds"), + (7, "7 seconds"), + (8, "8 seconds"), + (9, "9 seconds"), + (10, "10 seconds"), + (11, "11 seconds"), + (12, "12 seconds"), + (13, "13 seconds"), + (14, "14 seconds"), + (15, "15 seconds"), + (30, "30 seconds"), + (1 * 60, "1 minute"), + (1 * 60 * 60, "1 hour"), + (24 * 60 * 60, "1 day"), + (7 * 24 * 60 * 60, "1 week"), +] + class ChatMessageActionItemNode: ChatMessageItemView { let labelNode: TextNode let backgroundNode: ASImageNode @@ -95,6 +118,49 @@ class ChatMessageActionItemNode: ChatMessageItemView { attributedString = NSAttributedString(string: tr(.ChatServiceGroupJoinedByLink(authorName)), font: titleFont, textColor: UIColor.white) case .channelMigratedFromGroup, .groupMigratedToChannel: attributedString = NSAttributedString(string: tr(.ChatServiceGroupMigratedToSupergroup), font: titleFont, textColor: UIColor.white) + case let .messageAutoremoveTimeoutUpdated(timeout): + /* + "Notification.MessageLifetimeChanged" = "%1$@ set the self-destruct timer to %2$@"; + "Notification.MessageLifetimeChangedOutgoing" = "You set the self-destruct timer to %1$@"; + "Notification.MessageLifetimeRemoved" = "%1$@ disabled the self-destruct timer"; + "Notification.MessageLifetimeRemovedOutgoing" = "You disabled the self-destruct timer"; + */ + if timeout > 0 { + var timeValue: String = "\(timeout) s" + for (value, text) in timeoutValues { + if value == timeout { + timeValue = text + } + } + + let string: String + if item.message.author?.id == item.account.peerId { + string = String(format: NSLocalizedString("Notification.MessageLifetimeChangedOutgoing", comment: ""), timeValue) + } else { + let authorString: String + if let author = messageMainPeer(item.message) { + authorString = author.compactDisplayTitle + } else { + authorString = "" + } + string = String(format: NSLocalizedString("Notification.MessageLifetimeChanged", comment: ""), authorString, timeValue) + } + attributedString = NSAttributedString(string: string, font: titleFont, textColor: UIColor.white) + } else { + let string: String + if item.message.author?.id == item.account.peerId { + string = NSLocalizedString("Notification.MessageLifetimeRemovedOutgoing", comment: "") + } else { + let authorString: String + if let author = messageMainPeer(item.message) { + authorString = author.compactDisplayTitle + } else { + authorString = "" + } + string = String(format: NSLocalizedString("Notification.MessageLifetimeRemoved", comment: ""), authorString) + } + attributedString = NSAttributedString(string: string, font: titleFont, textColor: UIColor.white) + } default: attributedString = nil } diff --git a/TelegramUI/ChatMessageBubbleContentNode.swift b/TelegramUI/ChatMessageBubbleContentNode.swift index 8624d7f06c..b58392c884 100644 --- a/TelegramUI/ChatMessageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageBubbleContentNode.swift @@ -37,6 +37,7 @@ enum ChatMessageBubbleContentTapAction { case botCommand(String) case hashtag(String?, String) case instantPage + case holdToPreviewSecretMedia } class ChatMessageBubbleContentNode: ASDisplayNode { diff --git a/TelegramUI/ChatMessageBubbleItemNode.swift b/TelegramUI/ChatMessageBubbleItemNode.swift index 23c5df10fe..ce0e594c0f 100644 --- a/TelegramUI/ChatMessageBubbleItemNode.swift +++ b/TelegramUI/ChatMessageBubbleItemNode.swift @@ -125,22 +125,22 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { super.didLoad() let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) - recognizer.doNotWaitForDoubleTapAtPoint = { [weak self] point in + recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { if let nameNode = strongSelf.nameNode, nameNode.frame.contains(point) { if let item = strongSelf.item { for attribute in item.message.attributes { if let attribute = attribute as? InlineBotMessageAttribute { - return true + return .waitForSingleTap } } } } if let replyInfoNode = strongSelf.replyInfoNode, replyInfoNode.frame.contains(point) { - return true + return .waitForSingleTap } if let forwardInfoNode = strongSelf.forwardInfoNode, forwardInfoNode.frame.contains(point) { - return true + return .waitForSingleTap } for contentNode in strongSelf.contentNodes { let tapAction = contentNode.tapActionAtPoint(CGPoint(x: point.x - contentNode.frame.minX, y: point.y - contentNode.frame.minY)) @@ -148,11 +148,14 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { case .none: break case .url, .peerMention, .textMention, .botCommand, .hashtag, .instantPage: - return true + return .waitForSingleTap + case .holdToPreviewSecretMedia: + return .waitForHold } } } - return false + + return .waitForDoubleTap } self.view.addGestureRecognizer(recognizer) } @@ -539,7 +542,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } } else { if let _ = strongSelf.backgroundFrameTransition { - strongSelf.animateFrameTransition(1.0) + strongSelf.animateFrameTransition(1.0, backgroundFrame.size.height) strongSelf.backgroundFrameTransition = nil } strongSelf.backgroundNode.frame = backgroundFrame @@ -635,8 +638,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } } - override func animateFrameTransition(_ progress: CGFloat) { - super.animateFrameTransition(progress) + override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { + super.animateFrameTransition(progress, currentValue) if let backgroundFrameTransition = self.backgroundFrameTransition { let backgroundFrame = CGRect.interpolator()(backgroundFrameTransition.0, backgroundFrameTransition.1, progress) as! CGRect @@ -662,6 +665,12 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { + case .began: + if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture { + if let item = self.item, item.message.containsSecretMedia { + self.controllerInteraction?.openSecretMessagePreview(item.message.id) + } + } case .ended: if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { @@ -739,6 +748,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { controllerInteraction.openInstantPage(item.message.id) } break loop + case .holdToPreviewSecretMedia: + foundTapAction = true + break } } if !foundTapAction { @@ -748,8 +760,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { if let item = self.item, self.backgroundNode.frame.contains(location) { self.controllerInteraction?.openMessageContextMenu(item.message.id, self, self.backgroundNode.frame) } + case .hold: + if let item = self.item, item.message.containsSecretMedia { + self.controllerInteraction?.closeSecretMessagePreview() + } } } + case .cancelled: + if let item = self.item, item.message.containsSecretMedia { + self.controllerInteraction?.closeSecretMessagePreview() + } default: break } @@ -866,9 +886,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { case .requestPhone: controllerInteraction.shareAccountContact() case .openWebApp: - controllerInteraction.requestMessageActionCallback(item.message.id, nil) + controllerInteraction.requestMessageActionCallback(item.message.id, nil, true) case let .callback(data): - controllerInteraction.requestMessageActionCallback(item.message.id, data) + controllerInteraction.requestMessageActionCallback(item.message.id, data, false) case let .switchInline(samePeer, query): var botPeer: Peer? diff --git a/TelegramUI/ChatMessageDateHeader.swift b/TelegramUI/ChatMessageDateHeader.swift index 466a0eff9a..44442892d7 100644 --- a/TelegramUI/ChatMessageDateHeader.swift +++ b/TelegramUI/ChatMessageDateHeader.swift @@ -90,7 +90,7 @@ final class ChatMessageDateHeaderNode: ListViewItemHeaderNode { self.stickBackgroundNode.displayWithoutProcessing = true self.stickBackgroundNode.displaysAsynchronously = false - super.init(dynamicBounce: true) + super.init(dynamicBounce: true, isRotated: true) self.isLayerBacked = true self.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) diff --git a/TelegramUI/ChatMessageFileBubbleContentNode.swift b/TelegramUI/ChatMessageFileBubbleContentNode.swift index 9ec0298232..6d4bf5cd48 100644 --- a/TelegramUI/ChatMessageFileBubbleContentNode.swift +++ b/TelegramUI/ChatMessageFileBubbleContentNode.swift @@ -49,7 +49,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { } else { if item.message.flags.contains(.Failed) { statusType = .BubbleOutgoing(.Failed) - } else if item.message.flags.contains(.Unsent) { + } else if item.message.flags.isSending { statusType = .BubbleOutgoing(.Sending) } else { statusType = .BubbleOutgoing(.Sent(read: item.read)) diff --git a/TelegramUI/ChatMessageInteractiveMediaNode.swift b/TelegramUI/ChatMessageInteractiveMediaNode.swift index cd372881c7..36c0bf13bc 100644 --- a/TelegramUI/ChatMessageInteractiveMediaNode.swift +++ b/TelegramUI/ChatMessageInteractiveMediaNode.swift @@ -10,14 +10,18 @@ private struct FetchControls { let cancel: () -> Void } +private let secretMediaIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/SecretMediaIcon"), color: .white) + final class ChatMessageInteractiveMediaNode: ASTransformNode { private let imageNode: TransformImageNode private var progressNode: RadialProgressNode? + private var timeoutNode: RadialTimeoutNode? private var tapRecognizer: UITapGestureRecognizer? private var account: Account? private var messageIdAndFlags: (MessageId, MessageFlags)? private var media: Media? + private var message: Message? private let statusDisposable = MetaDisposable() private let fetchControls = Atomic(value: nil) @@ -50,7 +54,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { if let fetchStatus = self.fetchStatus { switch fetchStatus { case .Fetching: - if let account = self.account, let (messageId, flags) = self.messageIdAndFlags, flags.contains(.Unsent) && !flags.contains(.Failed) { + if let account = self.account, let (messageId, flags) = self.messageIdAndFlags, flags.isSending { account.postbox.modify({ modifier -> Void in modifier.deleteMessages([messageId]) }).start() @@ -70,7 +74,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { @objc func imageTap(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { - if let file = media as? TelegramMediaFile, (file.isVideo || file.isAnimated || file.mimeType.hasPrefix("video/")) { + if let file = self.media as? TelegramMediaFile, let message = self.message, (file.isVideo || file.isAnimated || file.mimeType.hasPrefix("video/")) && !message.containsSecretMedia { self.activateLocalContent() } else { if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { @@ -90,6 +94,19 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { return { account, message, media, corners, automaticDownload, constrainedSize, layoutConstants in var nativeSize: CGSize + var isSecretMedia = message.containsSecretMedia + var secretBeginTimeAndTimeout: (Double, Double)? + if isSecretMedia { + for attribute in message.attributes { + if let attribute = attribute as? AutoremoveTimeoutMessageAttribute { + if let countdownBeginTime = attribute.countdownBeginTime { + secretBeginTimeAndTimeout = (Double(countdownBeginTime), Double(attribute.timeout)) + } + break + } + } + } + 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 { @@ -101,10 +118,30 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { nativeSize = CGSize(width: 54.0, height: 54.0) } - return (layoutConstants.image.maxDimensions.width, { constrainedSize in - return (min(layoutConstants.image.maxDimensions.width, nativeSize.width), { boundingWidth in - let drawingSize = nativeSize.fittedToWidthOrSmaller(boundingWidth) - let boundingSize = CGSize(width: max(boundingWidth, drawingSize.width), height: drawingSize.height).cropped(CGSize(width: CGFloat.greatestFiniteMagnitude, height: layoutConstants.image.maxDimensions.height)) + let maxWidth: CGFloat + if isSecretMedia { + maxWidth = 180.0 + } else { + maxWidth = layoutConstants.image.maxDimensions.width + } + + var secretProgressIcon: UIImage? + if isSecretMedia { + secretProgressIcon = secretMediaIcon + } + + return (maxWidth, { constrainedSize in + return (min(maxWidth, nativeSize.width), { boundingWidth in + let drawingSize: CGSize + let boundingSize: CGSize + + if isSecretMedia { + boundingSize = CGSize(width: maxWidth, height: maxWidth) + drawingSize = nativeSize.aspectFilled(boundingSize) + } else { + drawingSize = nativeSize.fittedToWidthOrSmaller(boundingWidth) + boundingSize = CGSize(width: max(boundingWidth, drawingSize.width), height: drawingSize.height).cropped(CGSize(width: CGFloat.greatestFiniteMagnitude, height: layoutConstants.image.maxDimensions.height)) + } var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var updatedStatusSignal: Signal? @@ -124,22 +161,30 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { if mediaUpdated { if let image = media as? TelegramMediaImage { - updateImageSignal = chatMessagePhoto(account: account, photo: image) + if isSecretMedia { + updateImageSignal = chatSecretPhoto(account: account, photo: image) + } else { + updateImageSignal = chatMessagePhoto(account: account, photo: image) + } updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { strongSelf.fetchDisposable.set(chatMessagePhotoInteractiveFetched(account: account, photo: image).start()) } - }, cancel: { - chatMessagePhotoCancelInteractiveFetch(account: account, photo: image) + }, cancel: { + chatMessagePhotoCancelInteractiveFetch(account: account, photo: image) }) } else if let file = media as? TelegramMediaFile { - updateImageSignal = chatMessageVideo(account: account, video: file) + if isSecretMedia { + updateImageSignal = chatSecretMessageVideo(account: account, video: file) + } else { + updateImageSignal = chatMessageVideo(account: account, video: file) + } updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { strongSelf.fetchDisposable.set(chatMessageFileInteractiveFetched(account: account, file: file).start()) } - }, cancel: { + }, cancel: { chatMessageFileCancelInteractiveFetch(account: account, file: file) }) } @@ -147,7 +192,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { if statusUpdated { if let image = media as? TelegramMediaImage { - if message.flags.contains(.Unsent) && !message.flags.contains(.Failed) { + if message.flags.isSending { updatedStatusSignal = combineLatest(chatMessagePhotoStatus(account: account, photo: image), account.pendingMessageManager.pendingMessageStatus(message.id)) |> map { resourceStatus, pendingStatus -> MediaResourceStatus in if let pendingStatus = pendingStatus { @@ -182,54 +227,83 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { strongSelf.account = account strongSelf.messageIdAndFlags = (message.id, message.flags) strongSelf.media = media + strongSelf.message = message strongSelf.imageNode.frame = imageFrame strongSelf.progressNode?.position = CGPoint(x: imageFrame.midX, y: imageFrame.midY) + strongSelf.timeoutNode?.position = CGPoint(x: imageFrame.midX, y: imageFrame.midY) if let updateImageSignal = updateImageSignal { strongSelf.imageNode.setSignal(account: account, signal: updateImageSignal) } + if let secretBeginTimeAndTimeout = secretBeginTimeAndTimeout { + if strongSelf.timeoutNode == nil { + let timeoutNode = RadialTimeoutNode(backgroundColor: UIColor(white: 0.0, alpha: 0.6), foregroundColor: UIColor(white: 1.0, alpha: 0.6)) + timeoutNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 50.0, height: 50.0)) + timeoutNode.position = strongSelf.imageNode.position + strongSelf.timeoutNode = timeoutNode + strongSelf.addSubnode(timeoutNode) + timeoutNode.setTimeout(beginTimestamp: secretBeginTimeAndTimeout.0, timeout: secretBeginTimeAndTimeout.1) + } + + if let progressNode = strongSelf.progressNode { + progressNode.removeFromSupernode() + strongSelf.progressNode = nil + } + } else if let timeoutNode = strongSelf.timeoutNode { + timeoutNode.removeFromSupernode() + strongSelf.timeoutNode = nil + } + if let updatedStatusSignal = updatedStatusSignal { strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in displayLinkDispatcher.dispatch { if let strongSelf = strongSelf { strongSelf.fetchStatus = status - if let file = media as? TelegramMediaFile, (file.isVideo || file.mimeType.hasPrefix("video/")) { + var progressRequired = false + if secretBeginTimeAndTimeout == nil { + if case .Local = status { + if let file = media as? TelegramMediaFile, file.isVideo { + progressRequired = true + } else if isSecretMedia { + progressRequired = true + } + } else { + progressRequired = true + } + } + + if progressRequired { + if strongSelf.progressNode == nil { + let progressNode = RadialProgressNode() + progressNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 50.0, height: 50.0)) + progressNode.position = strongSelf.imageNode.position + strongSelf.progressNode = progressNode + strongSelf.addSubnode(progressNode) + } + } else { if let progressNode = strongSelf.progressNode { progressNode.removeFromSupernode() strongSelf.progressNode = nil } - } else { - if case .Local = status { - if let progressNode = strongSelf.progressNode { - progressNode.removeFromSupernode() - strongSelf.progressNode = nil - } - } else { - if strongSelf.progressNode == nil { - let progressNode = RadialProgressNode() - progressNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 50.0, height: 50.0)) - progressNode.position = strongSelf.imageNode.position - strongSelf.progressNode = progressNode - strongSelf.addSubnode(progressNode) - } - } - - switch status { - case let .Fetching(progress): - strongSelf.progressNode?.state = .Fetching(progress: progress) - case .Local: - var state: RadialProgressState = .None - if let file = media as? TelegramMediaFile { - if file.isVideo { - state = .Play - } + } + + switch status { + case let .Fetching(progress): + strongSelf.progressNode?.state = .Fetching(progress: progress) + case .Local: + var state: RadialProgressState = .None + if isSecretMedia && secretProgressIcon != nil { + state = .Image(secretProgressIcon!) + } else if let file = media as? TelegramMediaFile { + if file.isVideo { + state = .Play } - strongSelf.progressNode?.state = state - case .Remote: - strongSelf.progressNode?.state = .Remote - } + } + strongSelf.progressNode?.state = state + case .Remote: + strongSelf.progressNode?.state = .Remote } } } diff --git a/TelegramUI/ChatMessageMediaBubbleContentNode.swift b/TelegramUI/ChatMessageMediaBubbleContentNode.swift index 60bd46d7a8..71f02adee7 100644 --- a/TelegramUI/ChatMessageMediaBubbleContentNode.swift +++ b/TelegramUI/ChatMessageMediaBubbleContentNode.swift @@ -24,7 +24,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { self.interactiveImageNode.activateLocalContent = { [weak self] in if let strongSelf = self { - if let item = strongSelf.item, let controllerInteraction = strongSelf.controllerInteraction { + if let item = strongSelf.item, let controllerInteraction = strongSelf.controllerInteraction, !item.message.containsSecretMedia { controllerInteraction.openMessage(item.message.id) } } @@ -105,4 +105,13 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { self.interactiveImageNode.isHidden = mediaHidden } + + override func tapActionAtPoint(_ point: CGPoint) -> ChatMessageBubbleContentTapAction { + if self.interactiveImageNode.frame.contains(point) { + if let item = self.item, item.message.containsSecretMedia { + return .holdToPreviewSecretMedia + } + } + return .none + } } diff --git a/TelegramUI/ChatMessageStickerItemNode.swift b/TelegramUI/ChatMessageStickerItemNode.swift index 52aed4fa36..200f23eb1e 100644 --- a/TelegramUI/ChatMessageStickerItemNode.swift +++ b/TelegramUI/ChatMessageStickerItemNode.swift @@ -59,7 +59,9 @@ class ChatMessageStickerItemNode: ChatMessageItemView { let incoming = item.message.effectivelyIncoming var imageSize: CGSize = CGSize(width: 100.0, height: 100.0) if let telegramFile = telegramFile { - if let thumbnailSize = telegramFile.previewRepresentations.first?.dimensions { + if let dimensions = telegramFile.dimensions { + imageSize = dimensions.aspectFitted(displaySize) + } else if let thumbnailSize = telegramFile.previewRepresentations.first?.dimensions { imageSize = thumbnailSize.aspectFitted(displaySize) } } diff --git a/TelegramUI/ChatMessageTextBubbleContentNode.swift b/TelegramUI/ChatMessageTextBubbleContentNode.swift index 7e221bc84f..fb70bfdceb 100644 --- a/TelegramUI/ChatMessageTextBubbleContentNode.swift +++ b/TelegramUI/ChatMessageTextBubbleContentNode.swift @@ -72,7 +72,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } else { if message.flags.contains(.Failed) { statusType = .BubbleOutgoing(.Failed) - } else if message.flags.contains(.Unsent) { + } else if message.flags.isSending { statusType = .BubbleOutgoing(.Sending) } else { statusType = .BubbleOutgoing(.Sent(read: item.read)) @@ -99,6 +99,12 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { break } } + if entities == nil { + let parsedEntities = generateTextEntities(message.text) + if !parsedEntities.isEmpty { + entities = TextEntitiesMessageAttribute(entities: parsedEntities) + } + } if let entities = entities { attributedText = stringWithAppliedEntities(message.text, entities: entities.entities, baseFont: messageFont, boldFont: messageBoldFont, fixedFont: messageFixedFont) } else { diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index 21c7a131f8..962bb99e5c 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -184,7 +184,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } else { if item.message.flags.contains(.Failed) { statusType = .BubbleOutgoing(.Failed) - } else if item.message.flags.contains(.Unsent) { + } else if item.message.flags.isSending { statusType = .BubbleOutgoing(.Sending) } else { statusType = .BubbleOutgoing(.Sent(read: true)) diff --git a/TelegramUI/ChatPanelInterfaceInteraction.swift b/TelegramUI/ChatPanelInterfaceInteraction.swift index 4b80c842b2..bd33858947 100644 --- a/TelegramUI/ChatPanelInterfaceInteraction.swift +++ b/TelegramUI/ChatPanelInterfaceInteraction.swift @@ -31,9 +31,10 @@ final class ChatPanelInterfaceInteraction { let botSwitchChatWithPayload: (PeerId, String) -> Void let beginAudioRecording: () -> Void let finishAudioRecording: (Bool) -> Void + let setupMessageAutoremoveTimeout: () -> Void let statuses: ChatPanelInterfaceInteractionStatuses? - init(setupReplyMessage: @escaping (MessageId) -> Void, setupEditMessage: @escaping (MessageId) -> Void, beginMessageSelection: @escaping (MessageId) -> Void, deleteSelectedMessages: @escaping () -> Void, forwardSelectedMessages: @escaping () -> Void, updateTextInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void, updateInputModeAndDismissedButtonKeyboardMessageId: @escaping ((ChatPresentationInterfaceState) -> (ChatInputMode, MessageId?)) -> Void, editMessage: @escaping (MessageId, String) -> Void, beginMessageSearch: @escaping () -> Void, openPeerInfo: @escaping () -> Void, togglePeerNotifications: @escaping () -> Void, sendContextResult: @escaping (ChatContextResultCollection, ChatContextResult) -> Void, sendBotCommand: @escaping (Peer, String) -> Void, sendBotStart: @escaping (String?) -> Void, botSwitchChatWithPayload: @escaping (PeerId, String) -> Void, beginAudioRecording: @escaping () -> Void, finishAudioRecording: @escaping (Bool) -> Void, statuses: ChatPanelInterfaceInteractionStatuses?) { + init(setupReplyMessage: @escaping (MessageId) -> Void, setupEditMessage: @escaping (MessageId) -> Void, beginMessageSelection: @escaping (MessageId) -> Void, deleteSelectedMessages: @escaping () -> Void, forwardSelectedMessages: @escaping () -> Void, updateTextInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void, updateInputModeAndDismissedButtonKeyboardMessageId: @escaping ((ChatPresentationInterfaceState) -> (ChatInputMode, MessageId?)) -> Void, editMessage: @escaping (MessageId, String) -> Void, beginMessageSearch: @escaping () -> Void, openPeerInfo: @escaping () -> Void, togglePeerNotifications: @escaping () -> Void, sendContextResult: @escaping (ChatContextResultCollection, ChatContextResult) -> Void, sendBotCommand: @escaping (Peer, String) -> Void, sendBotStart: @escaping (String?) -> Void, botSwitchChatWithPayload: @escaping (PeerId, String) -> Void, beginAudioRecording: @escaping () -> Void, finishAudioRecording: @escaping (Bool) -> Void, setupMessageAutoremoveTimeout: @escaping () -> Void, statuses: ChatPanelInterfaceInteractionStatuses?) { self.setupReplyMessage = setupReplyMessage self.setupEditMessage = setupEditMessage self.beginMessageSelection = beginMessageSelection @@ -51,6 +52,7 @@ final class ChatPanelInterfaceInteraction { self.botSwitchChatWithPayload = botSwitchChatWithPayload self.beginAudioRecording = beginAudioRecording self.finishAudioRecording = finishAudioRecording + self.setupMessageAutoremoveTimeout = setupMessageAutoremoveTimeout self.statuses = statuses } } diff --git a/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift b/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift new file mode 100644 index 0000000000..b0d11f03f9 --- /dev/null +++ b/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift @@ -0,0 +1,134 @@ +import Foundation +import Display +import AsyncDisplayKit +import UIKit +import SwiftSignalKit +import Photos + +final class ChatSecretAutoremoveTimerActionSheetController: ActionSheetController { + private let _ready = Promise() + override var ready: Promise { + return self._ready + } + + init(currentValue: Int32, applyValue: @escaping (Int32) -> Void) { + super.init() + + self._ready.set(.single(true)) + + var updatedValue = currentValue + self.setItemGroups([ + ActionSheetItemGroup(items: [ + AutoremoveTimeoutSelectorItem(currentValue: currentValue, valueChanged: { value in + updatedValue = value + }), + ActionSheetButtonItem(title: "Set", action: { [weak self] in + if let strongSelf = self { + self?.dismissAnimated() + } + applyValue(updatedValue) + }) + ]), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", action: { [weak self] in + self?.dismissAnimated() + }), + ]) + ]) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class AutoremoveTimeoutSelectorItem: ActionSheetItem { + let currentValue: Int32 + let valueChanged: (Int32) -> Void + + init(currentValue: Int32, valueChanged: @escaping (Int32) -> Void) { + self.currentValue = currentValue + self.valueChanged = valueChanged + } + + func node() -> ActionSheetItemNode { + return AutoremoveTimeoutSelectorItemNode(currentValue: self.currentValue, valueChanged: self.valueChanged) + } +} + +private let timeoutValues: [(Int32, String)] = [ + (0, "Off"), + (1, "1 second"), + (2, "2 seconds"), + (3, "3 seconds"), + (4, "4 seconds"), + (5, "5 seconds"), + (6, "6 seconds"), + (7, "7 seconds"), + (8, "8 seconds"), + (9, "9 seconds"), + (10, "10 seconds"), + (11, "11 seconds"), + (12, "12 seconds"), + (13, "13 seconds"), + (14, "14 seconds"), + (15, "15 seconds"), + (30, "30 seconds"), + (1 * 60, "1 minute"), + (1 * 60 * 60, "1 hour"), + (24 * 60 * 60, "1 day"), + (7 * 24 * 60 * 60, "1 week"), +] + +private final class AutoremoveTimeoutSelectorItemNode: ActionSheetItemNode, UIPickerViewDelegate, UIPickerViewDataSource { + private let valueChanged: (Int32) -> Void + private let pickerView: UIPickerView + + init(currentValue: Int32, valueChanged: @escaping (Int32) -> Void) { + self.valueChanged = valueChanged + + self.pickerView = UIPickerView() + + super.init() + + self.pickerView.delegate = self + self.pickerView.dataSource = self + self.view.addSubview(self.pickerView) + + self.pickerView.reloadAllComponents() + var index: Int = 0 + for i in 0 ..< timeoutValues.count { + if currentValue <= timeoutValues[i].0 { + index = i + break + } + } + self.pickerView.selectRow(index, inComponent: 0, animated: false) + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + return CGSize(width: constrainedSize.width, height: 157.0) + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return timeoutValues.count + } + + func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? { + return NSAttributedString(string: timeoutValues[row].1, font: Font.medium(15.0), textColor: UIColor.black) + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + self.valueChanged(timeoutValues[row].0) + } + + override func layout() { + super.layout() + + self.pickerView.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.bounds.size.width, height: 180.0)) + } +} diff --git a/TelegramUI/ChatTextInputPanelNode.swift b/TelegramUI/ChatTextInputPanelNode.swift index 83d52184dd..ce9cd8d4e2 100644 --- a/TelegramUI/ChatTextInputPanelNode.swift +++ b/TelegramUI/ChatTextInputPanelNode.swift @@ -63,10 +63,40 @@ private let searchLayoutProgressImage = generateImage(CGSize(width: 22.0, height private let attachmentIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Text/IconAttachment"), color: UIColor(0x9099A2)) private let sendIcon = UIImage(bundleImageName: "Chat/Input/Text/IconSend")?.precomposed() -enum ChatTextInputAccessoryItem { +enum ChatTextInputAccessoryItem: Equatable { case keyboard case stickers case inputButtons + case messageAutoremoveTimeout(Int32?) + + static func ==(lhs: ChatTextInputAccessoryItem, rhs: ChatTextInputAccessoryItem) -> Bool { + switch lhs { + case .keyboard: + if case .keyboard = rhs { + return true + } else { + return false + } + case .stickers: + if case .stickers = rhs { + return true + } else { + return false + } + case .inputButtons: + if case .inputButtons = rhs { + return true + } else { + return false + } + case let .messageAutoremoveTimeout(lhsTimeout): + if case let .messageAutoremoveTimeout(rhsTimeout) = rhs, lhsTimeout == rhsTimeout { + return true + } else { + return false + } + } + } } struct ChatTextInputPanelAudioRecordingState: Equatable { @@ -122,9 +152,14 @@ private let keyboardImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryI private let stickersImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryIconStickers")?.precomposed() private let inputButtonsImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryIconInputButtons")?.precomposed() private let audioRecordingDotImage = generateFilledCircleImage(diameter: 9.0, color: UIColor(0xed2521)) +private let timerImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryIconTimer")?.precomposed() private final class AccessoryItemIconButton: HighlightableButton { + private let item: ChatTextInputAccessoryItem + init(item: ChatTextInputAccessoryItem) { + self.item = item + super.init(frame: CGRect()) switch item { @@ -134,6 +169,15 @@ private final class AccessoryItemIconButton: HighlightableButton { self.setImage(stickersImage, for: []) case .inputButtons: self.setImage(inputButtonsImage, for: []) + case let .messageAutoremoveTimeout(timeout): + if let timeout = timeout { + self.setImage(nil, for: []) + self.titleLabel?.font = Font.regular(12.0) + self.setTitleColor(UIColor.lightGray, for: []) + self.setTitle("\(timeout)s", for: []) + } else { + self.setImage(timerImage, for: []) + } } //self.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5) @@ -144,7 +188,12 @@ private final class AccessoryItemIconButton: HighlightableButton { } var buttonWidth: CGFloat { - return (self.image(for: [])?.size.width ?? 0.0) + CGFloat(8.0) + switch self.item { + case .keyboard, .stickers, .inputButtons: + return (self.image(for: [])?.size.width ?? 0.0) + CGFloat(8.0) + case let .messageAutoremoveTimeout(timeout): + return 24.0 + } } } @@ -832,6 +881,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { self.interfaceInteraction?.updateInputModeAndDismissedButtonKeyboardMessageId({ state in return (.inputButtons, nil) }) + case .messageAutoremoveTimeout: + self.interfaceInteraction?.setupMessageAutoremoveTimeout() } break } diff --git a/TelegramUI/ChatTitleView.swift b/TelegramUI/ChatTitleView.swift index 122fa7ca6e..1a85388827 100644 --- a/TelegramUI/ChatTitleView.swift +++ b/TelegramUI/ChatTitleView.swift @@ -4,19 +4,88 @@ import Display import Postbox import TelegramCore import SwiftSignalKit +import TelegramLegacyComponents final class ChatTitleView: UIView { private let titleNode: ASTextNode private let infoNode: ASTextNode + private let typingNode: ASTextNode + private var typingIndicator: TGModernConversationTitleActivityIndicator? private let button: HighlightTrackingButton private var presenceManager: PeerPresenceStatusManager? + var inputActivities: (PeerId, [(Peer, PeerInputActivity)])? { + didSet { + if let (peerId, inputActivities) = self.inputActivities, !inputActivities.isEmpty { + self.typingNode.isHidden = false + self.infoNode.isHidden = true + var stringValue = "" + var first = true + var mergedActivity = inputActivities[0].1 + for (_, activity) in inputActivities { + if activity != mergedActivity { + mergedActivity = .typingText + break + } + } + if peerId.namespace == Namespaces.Peer.CloudUser || peerId.namespace == Namespaces.Peer.SecretChat { + switch mergedActivity { + case .recordingVoice: + stringValue = "recording audio" + default: + stringValue = "typing..." + } + } else { + for (peer, _) in inputActivities { + let title = peer.compactDisplayTitle + if !title.isEmpty { + if first { + first = false + } else { + stringValue += ", " + } + stringValue += title + } + } + } + let string = NSAttributedString(string: stringValue, font: Font.regular(13.0), textColor: UIColor(0x007ee5)) + if self.typingNode.attributedText == nil || !self.typingNode.attributedText!.isEqual(to: string) { + self.typingNode.attributedText = string + self.setNeedsLayout() + } + if self.typingIndicator == nil { + let typingIndicator = TGModernConversationTitleActivityIndicator() + self.addSubview(typingIndicator) + self.typingIndicator = typingIndicator + } + switch mergedActivity { + case .typingText: + self.typingIndicator?.setTyping() + case .recordingVoice: + self.typingIndicator?.setAudioRecording() + case .uploadingFile: + self.typingIndicator?.setUploading() + case .playingGame: + self.typingIndicator?.setPlaying() + } + } else { + self.typingNode.isHidden = true + self.infoNode.isHidden = false + self.typingNode.attributedText = nil + if let typingIndicator = self.typingIndicator { + typingIndicator.removeFromSuperview() + self.typingIndicator = nil + } + } + } + } + var pressed: (() -> Void)? var peerView: PeerView? { didSet { - if let peerView = self.peerView, let peer = peerView.peers[peerView.peerId] { + if let peerView = self.peerView, let peer = peerViewMainPeer(peerView) { let string = NSAttributedString(string: peer.displayTitle, font: Font.medium(17.0), textColor: UIColor.black) if self.titleNode.attributedText == nil || !self.titleNode.attributedText!.isEqual(to: string) { @@ -31,7 +100,7 @@ final class ChatTitleView: UIView { private func updateStatus() { var shouldUpdateLayout = false - if let peerView = self.peerView, let peer = peerView.peers[peerView.peerId] { + if let peerView = self.peerView, let peer = peerViewMainPeer(peerView) { if let user = peer as? TelegramUser { if let _ = user.botInfo { let string = NSAttributedString(string: "bot", font: Font.regular(13.0), textColor: UIColor(0x787878)) @@ -39,7 +108,7 @@ final class ChatTitleView: UIView { self.infoNode.attributedText = string shouldUpdateLayout = true } - } else if let presence = peerView.peerPresences[peerView.peerId] as? TelegramUserPresence { + } else if let peer = peerViewMainPeer(peerView), let presence = peerView.peerPresences[peer.id] as? TelegramUserPresence { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 let (string, activity) = stringAndActivityForUserPresence(presence, relativeTo: Int32(timestamp)) let attributedString = NSAttributedString(string: string, font: Font.regular(13.0), textColor: activity ? UIColor(0x007ee5) : UIColor(0x787878)) @@ -131,12 +200,19 @@ final class ChatTitleView: UIView { self.infoNode.truncationMode = .byTruncatingTail self.infoNode.isOpaque = false + self.typingNode = ASTextNode() + self.typingNode.displaysAsynchronously = false + self.typingNode.maximumNumberOfLines = 1 + self.typingNode.truncationMode = .byTruncatingTail + self.typingNode.isOpaque = false + self.button = HighlightTrackingButton() super.init(frame: frame) self.addSubnode(self.titleNode) self.addSubnode(self.infoNode) + self.addSubnode(self.typingNode) self.addSubview(self.button) self.presenceManager = PeerPresenceStatusManager(update: { [weak self] in @@ -149,13 +225,17 @@ final class ChatTitleView: UIView { if highlighted { strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") strongSelf.infoNode.layer.removeAnimation(forKey: "opacity") + strongSelf.typingNode.layer.removeAnimation(forKey: "opacity") strongSelf.titleNode.alpha = 0.4 strongSelf.infoNode.alpha = 0.4 + strongSelf.typingNode.alpha = 0.4 } else { strongSelf.titleNode.alpha = 1.0 strongSelf.infoNode.alpha = 1.0 + strongSelf.typingNode.alpha = 1.0 strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.infoNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.typingNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } @@ -175,21 +255,29 @@ final class ChatTitleView: UIView { if size.height > 40.0 { let titleSize = self.titleNode.measure(size) let infoSize = self.infoNode.measure(size) + let typingSize = self.typingNode.measure(size) let titleInfoSpacing: CGFloat = 0.0 let combinedHeight = titleSize.height + infoSize.height + titleInfoSpacing self.titleNode.frame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) self.infoNode.frame = CGRect(origin: CGPoint(x: floor((size.width - infoSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0) + titleSize.height + titleInfoSpacing), size: infoSize) + self.typingNode.frame = CGRect(origin: CGPoint(x: floor((size.width - typingSize.width + 14.0) / 2.0), y: floor((size.height - combinedHeight) / 2.0) + titleSize.height + titleInfoSpacing), size: typingSize) + + if let typingIndicator = self.typingIndicator { + typingIndicator.frame = CGRect(x: self.typingNode.frame.origin.x - 24.0, y: self.typingNode.frame.origin.y, width: 24.0, height: 16.0) + } } else { let titleSize = self.titleNode.measure(CGSize(width: floor(size.width / 2.0), height: size.height)) let infoSize = self.infoNode.measure(CGSize(width: floor(size.width / 2.0), height: size.height)) + let typingSize = self.typingNode.measure(CGSize(width: floor(size.width / 2.0), height: size.height)) let titleInfoSpacing: CGFloat = 8.0 let combinedWidth = titleSize.width + infoSize.width + titleInfoSpacing self.titleNode.frame = CGRect(origin: CGPoint(x: floor((size.width - combinedWidth) / 2.0), y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) self.infoNode.frame = CGRect(origin: CGPoint(x: floor((size.width - combinedWidth) / 2.0 + titleSize.width + titleInfoSpacing), y: floor((size.height - infoSize.height) / 2.0)), size: infoSize) + self.typingNode.frame = CGRect(origin: CGPoint(x: floor((size.width - combinedWidth) / 2.0 + titleSize.width + titleInfoSpacing), y: floor((size.height - typingSize.height) / 2.0)), size: typingSize) } } diff --git a/TelegramUI/ChatUnreadItem.swift b/TelegramUI/ChatUnreadItem.swift index fe36a2f51d..cbee4c22d9 100644 --- a/TelegramUI/ChatUnreadItem.swift +++ b/TelegramUI/ChatUnreadItem.swift @@ -67,14 +67,15 @@ class ChatUnreadItemNode: ListViewItemNode { self.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) - self.scrollPositioningInsets = UIEdgeInsets(top: 6.0, left: 0.0, bottom: 5.0, right: 0.0) + self.scrollPositioningInsets = UIEdgeInsets(top: 5.0, left: 0.0, bottom: 6.0, right: 0.0) + self.canBeUsedAsScrollToItemAnchor = false } override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { super.animateInsertion(currentTimestamp, duration: duration, short: short) - self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) - self.labelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + //self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + //self.labelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) //self.transitionOffset = -self.bounds.size.height * 1.6 //self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp) @@ -82,8 +83,7 @@ class ChatUnreadItemNode: ListViewItemNode { } override func animateAdded(_ currentTimestamp: Double, duration: Double) { - self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.labelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { @@ -124,7 +124,7 @@ class ChatUnreadItemNode: ListViewItemNode { } } - override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + override public 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) diff --git a/TelegramUI/ChatVideoGalleryItem.swift b/TelegramUI/ChatVideoGalleryItem.swift index ea0070febd..a694aceb38 100644 --- a/TelegramUI/ChatVideoGalleryItem.swift +++ b/TelegramUI/ChatVideoGalleryItem.swift @@ -21,7 +21,7 @@ class ChatVideoGalleryItem: GalleryItem { for media in self.message.media { if let file = media as? TelegramMediaFile, (file.isVideo || file.mimeType.hasPrefix("video/")) { - node.setFile(account: account, file: file) + node.setFile(account: account, file: file, loopVideo: file.isAnimated || self.message.containsSecretMedia) break } } @@ -50,7 +50,7 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { private let videoNode: MediaPlayerNode private let scrubberView: ChatVideoGalleryItemScrubberView - private var accountAndFile: (Account, TelegramMediaFile)? + private var accountAndFile: (Account, TelegramMediaFile, Bool)? private var isCentral = false @@ -87,8 +87,8 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } - func setFile(account: Account, file: TelegramMediaFile) { - if self.accountAndFile == nil || !self.accountAndFile!.1.isEqual(file) { + 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 { self.snapshotNode.alphaTransitionOnFirstUpdate = false let displaySize = largestSize.dividedByScreenScale() @@ -100,7 +100,7 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { } let shouldPlayVideo = self.accountAndFile?.1 != file - self.accountAndFile = (account, file) + self.accountAndFile = (account, file, loopVideo) if shouldPlayVideo && self.isCentral { self.playVideo() } @@ -108,7 +108,7 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { } private func playVideo() { - if let (account, file) = self.accountAndFile { + if let (account, file, loopVideo) = self.accountAndFile { var dimensions: CGSize? = file.dimensions if dimensions == nil || dimensions!.width.isLessThanOrEqualTo(0.0) || dimensions!.height.isLessThanOrEqualTo(0.0) { dimensions = largestImageRepresentation(file.previewRepresentations)?.dimensions.aspectFitted(CGSize(width: 1920, height: 1080)) @@ -122,6 +122,9 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.videoNode.player = VideoPlayer(source: source)*/ let player = MediaPlayer(postbox: account.postbox, resource: file.resource) + if loopVideo { + player.actionAtEnd = .loop + } player.attachPlayerNode(self.videoNode) self.player = player self.videoStatusDisposable.set((player.status |> deliverOnMainQueue).start(next: { [weak self] status in @@ -131,7 +134,6 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { })) player.play() - self.zoomableContent = (dimensions, self.videoNode) } } diff --git a/TelegramUI/ComposeController.swift b/TelegramUI/ComposeController.swift new file mode 100644 index 0000000000..abaf1b4ecc --- /dev/null +++ b/TelegramUI/ComposeController.swift @@ -0,0 +1,125 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import SwiftSignalKit +import TelegramCore + +public class ComposeController: ViewController { + private let account: Account + + private var contactsNode: ComposeControllerNode { + return self.displayNode as! ComposeControllerNode + } + + private let index: PeerNameIndex = .lastNameFirst + + private var _ready = Promise() + override public var ready: Promise { + return self._ready + } + + private let createActionDisposable = MetaDisposable() + + public init(account: Account) { + self.account = account + + super.init() + + self.title = "Mew Message" + + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + strongSelf.contactsNode.contactListNode.scrollToTop() + } + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.createActionDisposable.dispose() + } + + override public func loadDisplayNode() { + self.displayNode = ComposeControllerNode(account: self.account) + self._ready.set(self.contactsNode.contactListNode.ready) + + self.contactsNode.navigationBar = self.navigationBar + + self.contactsNode.requestDeactivateSearch = { [weak self] in + self?.deactivateSearch() + } + + self.contactsNode.requestOpenPeerFromSearch = { [weak self] peerId in + self?.openPeer(peerId: peerId) + } + + self.contactsNode.contactListNode.activateSearch = { [weak self] in + self?.activateSearch() + } + + self.contactsNode.contactListNode.openPeer = { [weak self] peer in + self?.openPeer(peerId: peer.id) + } + + self.contactsNode.openCreateNewGroup = { [weak self] in + if let strongSelf = self { + let controller = ContactMultiselectionController(account: strongSelf.account, mode: .groupCreation) + (strongSelf.navigationController as? NavigationController)?.pushViewController(controller) + strongSelf.createActionDisposable.set((controller.result + |> deliverOnMainQueue).start(next: { [weak controller] peerIds in + if let strongSelf = self, let controller = controller { + let createGroup = createGroupController(account: strongSelf.account, peerIds: peerIds) + (controller.navigationController as? NavigationController)?.pushViewController(createGroup) + } + })) + } + } + + self.displayNodeDidLoad() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.contactsNode.contactListNode.enableUpdates = true + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.contactsNode.contactListNode.enableUpdates = false + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.contactsNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } + + private func activateSearch() { + if self.displayNavigationBar { + if let scrollToTop = self.scrollToTop { + scrollToTop() + } + self.contactsNode.activateSearch() + self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring)) + } + } + + private func deactivateSearch() { + if !self.displayNavigationBar { + self.contactsNode.deactivateSearch() + self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring)) + } + } + + private func openPeer(peerId: PeerId) { + (self.navigationController as? NavigationController)?.replaceTopController(ChatController(account: self.account, peerId: peerId), animated: true) + } +} diff --git a/TelegramUI/ComposeControllerNode.swift b/TelegramUI/ComposeControllerNode.swift new file mode 100644 index 0000000000..3675b12495 --- /dev/null +++ b/TelegramUI/ComposeControllerNode.swift @@ -0,0 +1,128 @@ +import Display +import AsyncDisplayKit +import UIKit +import Postbox +import TelegramCore + +private let createGroupIcon = UIImage(bundleImageName: "Contact List/CreateGroupActionIcon")?.precomposed() +private let createSecretChatIcon = UIImage(bundleImageName: "Contact List/CreateSecretChatActionIcon")?.precomposed() +private let createChannelIcon = UIImage(bundleImageName: "Contact List/CreateChannelActionIcon")?.precomposed() + +final class ComposeControllerNode: ASDisplayNode { + let contactListNode: ContactListNode + + private let account: Account + private var searchDisplayController: SearchDisplayController? + + private var containerLayout: (ContainerViewLayout, CGFloat)? + + var navigationBar: NavigationBar? + + var requestDeactivateSearch: (() -> Void)? + var requestOpenPeerFromSearch: ((PeerId) -> Void)? + + var openCreateNewGroup: (() -> Void)? + var openCreateNewSecretChat: (() -> Void)? + var openCreateNewChannel: (() -> Void)? + + init(account: Account) { + self.account = account + + var openCreateNewGroupImpl: (() -> Void)? + var openCreateNewSecretChatImpl: (() -> Void)? + var openCreateNewChannelImpl: (() -> Void)? + + self.contactListNode = ContactListNode(account: account, presentation: .natural(displaySearch: true, options: [ + ContactListAdditionalOption(title: "New Group", icon: createGroupIcon, action: { + openCreateNewGroupImpl?() + }), + ContactListAdditionalOption(title: "New Secret Chat", icon: createSecretChatIcon, action: { + openCreateNewSecretChatImpl?() + }), + ContactListAdditionalOption(title: "New Channel", icon: createChannelIcon, action: { + openCreateNewChannelImpl?() + }) + ])) + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = UIColor.white + + self.addSubnode(self.contactListNode) + + openCreateNewGroupImpl = { [weak self] in + self?.openCreateNewGroup?() + } + openCreateNewSecretChatImpl = { [weak self] in + self?.openCreateNewSecretChat?() + } + openCreateNewChannelImpl = { [weak self] in + self?.openCreateNewChannel?() + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (layout, navigationBarHeight) + + var insets = layout.insets(options: [.input]) + insets.top += navigationBarHeight + + self.contactListNode.containerLayoutUpdated(ContainerViewLayout(size: layout.size, intrinsicInsets: insets, statusBarHeight: layout.statusBarHeight, inputHeight: layout.inputHeight), transition: transition) + + self.contactListNode.frame = CGRect(origin: CGPoint(), size: layout.size) + + if let searchDisplayController = self.searchDisplayController { + searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + } + } + + func activateSearch() { + guard let (containerLayout, navigationBarHeight) = self.containerLayout, let navigationBar = self.navigationBar else { + return + } + + var maybePlaceholderNode: SearchBarPlaceholderNode? + self.contactListNode.listNode.forEachItemNode { node in + if let node = node as? ChatListSearchItemNode { + maybePlaceholderNode = node.searchBarNode + } + } + + if let _ = self.searchDisplayController { + return + } + + if let placeholderNode = maybePlaceholderNode { + self.searchDisplayController = SearchDisplayController(contentNode: ContactsSearchContainerNode(account: self.account, openPeer: { [weak self] peerId in + if let requestOpenPeerFromSearch = self?.requestOpenPeerFromSearch { + requestOpenPeerFromSearch(peerId) + } + }), cancel: { [weak self] in + if let requestDeactivateSearch = self?.requestDeactivateSearch { + requestDeactivateSearch() + } + }) + + self.searchDisplayController?.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationBarHeight, transition: .immediate) + self.searchDisplayController?.activate(insertSubnode: { subnode in + self.insertSubnode(subnode, belowSubnode: navigationBar) + }, placeholder: placeholderNode) + } + } + + func deactivateSearch() { + if let searchDisplayController = self.searchDisplayController { + var maybePlaceholderNode: SearchBarPlaceholderNode? + self.contactListNode.listNode.forEachItemNode { node in + if let node = node as? ChatListSearchItemNode { + maybePlaceholderNode = node.searchBarNode + } + } + + searchDisplayController.deactivate(placeholder: maybePlaceholderNode) + self.searchDisplayController = nil + } + } +} diff --git a/TelegramUI/ContactListActionItem.swift b/TelegramUI/ContactListActionItem.swift new file mode 100644 index 0000000000..7d2bf8c3e4 --- /dev/null +++ b/TelegramUI/ContactListActionItem.swift @@ -0,0 +1,191 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +class ContactListActionItem: ListViewItem { + let title: String + let icon: UIImage? + let action: () -> Void + + init(title: String, icon: UIImage?, action: @escaping () -> Void) { + self.title = title + self.icon = icon + self.action = action + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ContactListActionItemNode() + let (layout, apply) = node.asyncLayout()(self, width) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply() }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? ContactListActionItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + self.action() + } +} + +private let titleFont = Font.regular(17.0) + +class ContactListActionItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + private let iconNode: ASImageNode + private let titleNode: TextNode + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.bottomStripeNode.isLayerBacked = true + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + self.iconNode = ASImageNode() + self.iconNode.isLayerBacked = true + self.iconNode.displayWithoutProcessing = true + self.iconNode.displaysAsynchronously = false + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.iconNode) + self.addSubnode(self.titleNode) + } + + func asyncLayout() -> (_ item: ContactListActionItem, _ width: CGFloat) -> (ListViewItemNodeLayout, () -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { item, width in + let leftInset: CGFloat = 65.0 + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: UIColor(0x007ee5)), nil, 1, .end, CGSize(width: width - 10.0 - leftInset, height: CGFloat.greatestFiniteMagnitude), nil) + + let contentSize = CGSize(width: width, height: 48.0) + let insets = UIEdgeInsets() + let separatorHeight = UIScreenPixel + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + + return (layout, { [weak self] in + if let strongSelf = self { + let _ = titleApply() + + strongSelf.iconNode.image = item.icon + + if let image = item.icon { + strongSelf.iconNode.frame = CGRect(origin: CGPoint(x: floor((leftInset - image.size.width) / 2.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size) + } + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + + strongSelf.topStripeNode.isHidden = true + + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height), size: CGSize(width: width - leftInset, height: separatorHeight)) + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((contentSize.height - titleLayout.size.height) / 2.0)), size: titleLayout.size) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 48.0 + UIScreenPixel + UIScreenPixel)) + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + var anchorNode: ASDisplayNode? + if self.bottomStripeNode.supernode != nil { + anchorNode = self.bottomStripeNode + } else if self.topStripeNode.supernode != nil { + anchorNode = self.topStripeNode + } else if self.backgroundNode.supernode != nil { + anchorNode = self.backgroundNode + } + if let anchorNode = anchorNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) + } else { + self.addSubnode(self.highlightedBackgroundNode) + } + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/TelegramUI/ContactListNameIndexHeader.swift b/TelegramUI/ContactListNameIndexHeader.swift new file mode 100644 index 0000000000..9774e00993 --- /dev/null +++ b/TelegramUI/ContactListNameIndexHeader.swift @@ -0,0 +1,46 @@ +import Display + +final class ContactListNameIndexHeader: Equatable, ListViewItemHeader { + let id: Int64 + let letter: unichar + let stickDirection: ListViewItemHeaderStickDirection = .top + + let height: CGFloat = 29.0 + + init(letter: unichar) { + self.letter = letter + self.id = Int64(letter) + } + + func node() -> ListViewItemHeaderNode { + return ContactListNameIndexHeaderNode(letter: self.letter) + } + + static func ==(lhs: ContactListNameIndexHeader, rhs: ContactListNameIndexHeader) -> Bool { + return lhs.id == rhs.id + } +} + +final class ContactListNameIndexHeaderNode: ListViewItemHeaderNode { + private let letter: unichar + + private let sectionHeaderNode: ListSectionHeaderNode + + init(letter: unichar) { + self.letter = letter + + self.sectionHeaderNode = ListSectionHeaderNode() + + super.init() + + if let scalar = UnicodeScalar(letter) { + self.sectionHeaderNode.title = "\(Character(scalar))" + } + + self.addSubnode(self.sectionHeaderNode) + } + + override func layout() { + self.sectionHeaderNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + } +} diff --git a/TelegramUI/ContactListNode.swift b/TelegramUI/ContactListNode.swift new file mode 100644 index 0000000000..5b1adbf124 --- /dev/null +++ b/TelegramUI/ContactListNode.swift @@ -0,0 +1,582 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +private enum ContactListNodeEntryId: Hashable { + case search + case vcard + case option(index: Int) + case peerId(Int64) + + var hashValue: Int { + switch self { + case .search: + return 0 + case .vcard: + return 1 + case let .option(index): + return (index + 2).hashValue + case let .peerId(peerId): + return peerId.hashValue + } + } + + static func <(lhs: ContactListNodeEntryId, rhs: ContactListNodeEntryId) -> Bool { + return lhs.hashValue < rhs.hashValue + } + + static func ==(lhs: ContactListNodeEntryId, rhs: ContactListNodeEntryId) -> Bool { + switch lhs { + case .search: + switch rhs { + case .search: + return true + default: + return false + } + case .vcard: + switch rhs { + case .vcard: + return true + default: + return false + } + case let .option(index): + if case .option(index) = rhs { + return true + } else { + return false + } + case let .peerId(lhsId): + switch rhs { + case let .peerId(rhsId): + return lhsId == rhsId + default: + return false + } + } + } +} + +private final class ContactListNodeInteraction { + let activateSearch: () -> Void + let openPeer: (Peer) -> Void + + init(activateSearch: @escaping () -> Void, openPeer: @escaping (Peer) -> Void) { + self.activateSearch = activateSearch + self.openPeer = openPeer + } +} + +private enum ContactListNodeEntry: Comparable, Identifiable { + case search + case vcard(Peer) + case option(Int, ContactListAdditionalOption) + case peer(Int, Peer, PeerPresence?, ContactListNameIndexHeader?, ContactsPeerItemSelection) + + var stableId: ContactListNodeEntryId { + switch self { + case .search: + return .search + case .vcard: + return .vcard + case let .option(index, _): + return .option(index: index) + case let .peer(_, peer, _, _, _): + return .peerId(peer.id.toInt64()) + } + } + + func item(account: Account, interaction: ContactListNodeInteraction) -> ListViewItem { + switch self { + case .search: + return ChatListSearchItem(placeholder: "Search contacts", activate: { + interaction.activateSearch() + }) + case let .vcard(peer): + return ContactsVCardItem(account: account, peer: peer, action: { peer in + interaction.openPeer(peer) + }) + case let .option(_, option): + return ContactListActionItem(title: option.title, icon: option.icon, action: option.action) + case let .peer(_, peer, presence, header, selection): + let status: ContactsPeerItemStatus + if let presence = presence { + status = .presence(presence) + } else { + status = .none + } + return ContactsPeerItem(account: account, peer: peer, chatPeer: peer, status: status, selection: selection, index: nil, header: header, action: { _ in + interaction.openPeer(peer) + }) + } + } + + static func ==(lhs: ContactListNodeEntry, rhs: ContactListNodeEntry) -> Bool { + switch lhs { + case .search: + switch rhs { + case .search: + return true + default: + return false + } + case let .vcard(lhsPeer): + switch rhs { + case let .vcard(rhsPeer): + return lhsPeer.id == rhsPeer.id + default: + return false + } + case let .option(index, option): + if case .option(index, option) = rhs { + return true + } else { + return false + } + case let .peer(lhsIndex, lhsPeer, lhsPresence, lhsHeader, lhsSelection): + switch rhs { + case let .peer(rhsIndex, rhsPeer, rhsPresence, rhsHeader, rhsSelection): + if lhsIndex != rhsIndex { + return false + } + if lhsPeer.id != rhsPeer.id { + return false + } + if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence { + if !lhsPresence.isEqual(to: rhsPresence) { + return false + } + } else if (lhsPresence != nil) != (rhsPresence != nil) { + return false + } + if lhsHeader != rhsHeader { + return false + } + if lhsSelection != rhsSelection { + return false + } + return true + default: + return false + } + } + } + + static func <(lhs: ContactListNodeEntry, rhs: ContactListNodeEntry) -> Bool { + switch lhs { + case .search: + return true + case .vcard: + switch rhs { + case .search, .vcard: + return false + case .peer, .option: + return true + } + case let .option(lhsIndex, _): + switch rhs { + case .search, .vcard: + return false + case let .option(rhsIndex, _): + return lhsIndex < rhsIndex + case .peer: + return true + } + case let .peer(lhsIndex, _, _, _, _): + switch rhs { + case .search, .vcard, .option: + return false + case let .peer(rhsIndex, _, _, _, _): + return lhsIndex < rhsIndex + } + } + } +} + +private extension PeerIndexNameRepresentation { + func isLessThan(other: PeerIndexNameRepresentation) -> ComparisonResult { + switch self { + case let .title(lhsTitle, _): + switch other { + case let .title(title, _): + return lhsTitle.compare(title) + case let .personName(_, last, _): + let lastResult = lhsTitle.compare(last) + if lastResult == .orderedSame { + return .orderedAscending + } else { + return lastResult + } + } + case let .personName(lhsFirst, lhsLast, _): + switch other { + case let .title(title, _): + let lastResult = lhsFirst.compare(title) + if lastResult == .orderedSame { + return .orderedDescending + } else { + return lastResult + } + case let .personName(first, last, _): + let lastResult = lhsLast.compare(last) + if lastResult == .orderedSame { + return lhsFirst.compare(first) + } else { + return lastResult + } + } + } + } +} + +private func contactListNodeEntries(view: ContactPeersView, presentation: ContactListPresentation, selectionState: ContactListNodeGroupSelectionState?) -> [ContactListNodeEntry] { + var entries: [ContactListNodeEntry] = [] + + var orderedPeers: [Peer] + var headers: [PeerId: ContactListNameIndexHeader] = [:] + + switch presentation { + case let .orderedByPresence(displayVCard): + if displayVCard { + if let peer = view.accountPeer { + entries.append(.vcard(peer)) + } + } + orderedPeers = view.peers.sorted(by: { lhs, rhs in + let lhsPresence = view.peerPresences[lhs.id] + let rhsPresence = view.peerPresences[rhs.id] + if let lhsPresence = lhsPresence as? TelegramUserPresence, let rhsPresence = rhsPresence as? TelegramUserPresence { + if lhsPresence.status < rhsPresence.status { + return false + } else if lhsPresence.status > rhsPresence.status { + return true + } + } else if let _ = lhsPresence { + return true + } else if let _ = rhsPresence { + return false + } + return lhs.id < rhs.id + }) + entries.append(.search) + case let .natural(displaySearch, options): + orderedPeers = view.peers.sorted(by: { lhs, rhs in + let result = lhs.indexName.isLessThan(other: rhs.indexName) + if result == .orderedSame { + return lhs.id < rhs.id + } else { + return result == .orderedAscending + } + }) + var headerCache: [unichar: ContactListNameIndexHeader] = [:] + for peer in orderedPeers { + var indexHeader: unichar = 35 + switch peer.indexName { + case let .title(title, _): + if let c = title.utf16.first { + indexHeader = c + } + case let .personName(first, last, _): + if let c = last.utf16.first { + indexHeader = c + } else if let c = first.utf16.first { + indexHeader = c + } + } + let header: ContactListNameIndexHeader + if let cached = headerCache[indexHeader] { + header = cached + } else { + header = ContactListNameIndexHeader(letter: indexHeader) + headerCache[indexHeader] = header + } + headers[peer.id] = header + } + if displaySearch { + entries.append(.search) + } + for i in 0 ..< options.count { + entries.append(.option(i, options[i])) + } + } + + var removeIndices: [Int] = [] + for i in 0 ..< orderedPeers.count { + switch orderedPeers[i].indexName { + case let .title(title, _): + if title.isEmpty { + removeIndices.append(i) + } + case let .personName(first, last, _): + if first.isEmpty || last.isEmpty { + removeIndices.append(i) + } + } + } + if !removeIndices.isEmpty { + for index in removeIndices.reversed() { + orderedPeers.remove(at: index) + } + } + + for i in 0 ..< orderedPeers.count { + let selection: ContactsPeerItemSelection + if let selectionState = selectionState { + selection = .selectable(selected: selectionState.selectedPeerIndices[orderedPeers[i].id] != nil) + } else { + selection = .none + } + entries.append(.peer(i, orderedPeers[i], view.peerPresences[orderedPeers[i].id], headers[orderedPeers[i].id], selection)) + } + return entries +} + +private func preparedContactListNodeTransition(account: Account, from fromEntries: [ContactListNodeEntry], to toEntries: [ContactListNodeEntry], interaction: ContactListNodeInteraction, firstTime: Bool, animated: Bool) -> ContactsListNodeTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, interaction: interaction), directionHint: nil) } + + return ContactsListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, firstTime: firstTime, animated: animated) +} + +private struct ContactsListNodeTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let firstTime: Bool + let animated: Bool +} + +struct ContactListAdditionalOption: Equatable { + let title: String + let icon: UIImage? + let action: () -> Void + + static func ==(lhs: ContactListAdditionalOption, rhs: ContactListAdditionalOption) -> Bool { + return lhs.title == rhs.title && lhs.icon === rhs.icon + } +} + +enum ContactListPresentation { + case orderedByPresence(displayVCard: Bool) + case natural(displaySearch: Bool, options: [ContactListAdditionalOption]) +} + +struct ContactListNodeGroupSelectionState: Equatable { + let selectedPeerIndices: [PeerId: Int] + let nextSelectionIndex: Int + + private init(selectedPeerIndices: [PeerId: Int], nextSelectionIndex: Int) { + self.selectedPeerIndices = selectedPeerIndices + self.nextSelectionIndex = nextSelectionIndex + } + + init() { + self.selectedPeerIndices = [:] + self.nextSelectionIndex = 0 + } + + func withToggledPeerId(_ peerId: PeerId) -> ContactListNodeGroupSelectionState { + var updatedIndices = self.selectedPeerIndices + if let _ = updatedIndices[peerId] { + updatedIndices.removeValue(forKey: peerId) + return ContactListNodeGroupSelectionState(selectedPeerIndices: updatedIndices, nextSelectionIndex: self.nextSelectionIndex) + } else { + updatedIndices[peerId] = self.nextSelectionIndex + return ContactListNodeGroupSelectionState(selectedPeerIndices: updatedIndices, nextSelectionIndex: self.nextSelectionIndex + 1) + } + } + + static func ==(lhs: ContactListNodeGroupSelectionState, rhs: ContactListNodeGroupSelectionState) -> Bool { + return lhs.selectedPeerIndices == rhs.selectedPeerIndices && lhs.nextSelectionIndex == rhs.nextSelectionIndex + } +} + +final class ContactListNode: ASDisplayNode { + private let account: Account + private let presentation: ContactListPresentation + + let listNode: ListView + + private var queuedTransitions: [ContactsListNodeTransition] = [] + private var hasValidLayout = false + + private var _ready = ValuePromise() + var ready: Signal { + return self._ready.get() + } + private var didSetReady = false + + private var enableUpdatesValue = true + private let enableUpdatesPromise = ValuePromise(true, ignoreRepeated: true) + + private let selectionStatePromise = Promise(nil) + private var selectionStateValue: ContactListNodeGroupSelectionState? { + didSet { + self.selectionStatePromise.set(.single(self.selectionStateValue)) + } + } + + var enableUpdates: Bool { + get { + return self.enableUpdatesValue + } set(value) { + self.enableUpdatesValue = value + self.enableUpdatesPromise.set(value) + } + } + + var activateSearch: (() -> Void)? + var openPeer: ((Peer) -> Void)? + + private let previousEntries = Atomic<[ContactListNodeEntry]?>(value: nil) + private let disposable = MetaDisposable() + + init(account: Account, presentation: ContactListPresentation, selectionState: ContactListNodeGroupSelectionState? = nil) { + self.account = account + self.presentation = presentation + + self.listNode = ListView() + + super.init() + + self.selectionStateValue = selectionState + self.selectionStatePromise.set(.single(selectionState)) + + self.addSubnode(self.listNode) + + let processingQueue = Queue() + let previousEntries = Atomic<[ContactListNodeEntry]?>(value: nil) + + let interaction = ContactListNodeInteraction(activateSearch: { [weak self] in + self?.activateSearch?() + }, openPeer: { [weak self] peer in + self?.openPeer?(peer) + }) + + let account = self.account + var firstTime: Int32 = 1 + let selectionStateSignal = self.selectionStatePromise.get() + let transition = self.enableUpdatesPromise.get() + |> mapToSignal { enableUpdates -> Signal in + if enableUpdates { + return combineLatest(account.postbox.contactPeersView(accountPeerId: account.peerId), selectionStateSignal) + |> mapToQueue { view, selectionState -> Signal in + let signal = deferred { () -> Signal in + let entries = contactListNodeEntries(view: view, presentation: presentation, selectionState: selectionState) + let previous = previousEntries.swap(entries) + let animated: Bool + if let previous = previous { + animated = (entries.count - previous.count) < 20 + } else { + animated = false + } + return .single(preparedContactListNodeTransition(account: account, from: previous ?? [], to: entries, interaction: interaction, firstTime: previous == nil, animated: animated)) + } + + if OSAtomicCompareAndSwap32(1, 0, &firstTime) { + return signal |> runOn(Queue.mainQueue()) + } else { + return signal |> runOn(processingQueue) + } + } + } else { + return .never() + } + } |> deliverOnMainQueue + self.disposable.set(transition.start(next: { [weak self] transition in + self?.enqueueTransition(transition) + })) + } + + deinit { + self.disposable.dispose() + } + + func updateSelectionState(_ f: (ContactListNodeGroupSelectionState?) -> ContactListNodeGroupSelectionState?) { + let updatedSelectionState = f(self.selectionStateValue) + if updatedSelectionState != self.selectionStateValue { + self.selectionStateValue = updatedSelectionState + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + let insets = layout.insets(options: [.input]) + + self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + self.listNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + + var duration: Double = 0.0 + var curve: UInt = 0 + switch transition { + case .immediate: + break + case let .animated(animationDuration, animationCurve): + duration = animationDuration + switch animationCurve { + case .easeInOut: + break + case .spring: + curve = 7 + } + } + + let listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: listViewCurve) + + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !self.hasValidLayout { + self.hasValidLayout = true + self.dequeueTransitions() + } + } + + private func enqueueTransition(_ transition: ContactsListNodeTransition) { + self.queuedTransitions.append(transition) + + if self.hasValidLayout { + self.dequeueTransitions() + } + } + + private func dequeueTransitions() { + if self.hasValidLayout { + while !self.queuedTransitions.isEmpty { + let transition = self.queuedTransitions.removeFirst() + + var options = ListViewDeleteAndInsertOptions() + if transition.firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else if transition.animated { + options.insert(.AnimateInsertion) + } + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self { + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + strongSelf._ready.set(true) + } + } + }) + } + } + } + + func scrollToTop() { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Default, directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } +} diff --git a/TelegramUI/ContactMultiselectionController.swift b/TelegramUI/ContactMultiselectionController.swift new file mode 100644 index 0000000000..717f8a7e1b --- /dev/null +++ b/TelegramUI/ContactMultiselectionController.swift @@ -0,0 +1,139 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import SwiftSignalKit +import TelegramCore + +public enum ContactMultiselectionControllerMode { + case groupCreation +} + +public class ContactMultiselectionController: ViewController { + private let account: Account + private let mode: ContactMultiselectionControllerMode + + private let titleView: CounterContollerTitleView + + private var contactsNode: ContactMultiselectionControllerNode { + return self.displayNode as! ContactMultiselectionControllerNode + } + + private let index: PeerNameIndex = .lastNameFirst + + private var _ready = Promise() + override public var ready: Promise { + return self._ready + } + + private let _result = Promise<[PeerId]>() + public var result: Signal<[PeerId], NoError> { + return self._result.get() + } + + private var rightNavigationButton: UIBarButtonItem? + + public init(account: Account, mode: ContactMultiselectionControllerMode) { + self.account = account + self.mode = mode + + self.titleView = CounterContollerTitleView() + + super.init() + + switch mode { + case .groupCreation: + self.titleView.title = CounterContollerTitle(title: "New Group", counter: "0/5000") + let rightNavigationButton = UIBarButtonItem(title: "Next", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) + self.rightNavigationButton = rightNavigationButton + self.navigationItem.rightBarButtonItem = self.rightNavigationButton + rightNavigationButton.isEnabled = false + } + + self.navigationItem.titleView = self.titleView + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + strongSelf.contactsNode.contactListNode.scrollToTop() + } + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func loadDisplayNode() { + self.displayNode = ContactMultiselectionControllerNode(account: self.account) + self._ready.set(self.contactsNode.contactListNode.ready) + + self.contactsNode.contactListNode.openPeer = { [weak self] peer in + if let strongSelf = self { + var updatedCount: Int? + var addedToken: EditableTokenListToken? + var removedTokenId: AnyHashable? + + strongSelf.contactsNode.contactListNode.updateSelectionState { state in + if let state = state { + let updatedState = state.withToggledPeerId(peer.id) + if updatedState.selectedPeerIndices[peer.id] == nil { + removedTokenId = peer.id + } else { + addedToken = EditableTokenListToken(id: peer.id, title: peer.displayTitle) + } + updatedCount = updatedState.selectedPeerIndices.count + return updatedState + } else { + return nil + } + } + + if let updatedCount = updatedCount { + strongSelf.rightNavigationButton?.isEnabled = updatedCount != 0 + strongSelf.titleView.title = CounterContollerTitle(title: "New Group", counter: "\(updatedCount)/5000") + } + + if let addedToken = addedToken { + strongSelf.contactsNode.editableTokens.append(addedToken) + } else if let removedTokenId = removedTokenId { + strongSelf.contactsNode.editableTokens = strongSelf.contactsNode.editableTokens.filter { token in + return token.id != removedTokenId + } + } + strongSelf.requestLayout(transition: ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)) + } + } + + self.displayNodeDidLoad() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.contactsNode.contactListNode.enableUpdates = true + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.contactsNode.contactListNode.enableUpdates = false + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.contactsNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } + + @objc func rightNavigationButtonPressed() { + var peerIds: [PeerId] = [] + self.contactsNode.contactListNode.updateSelectionState { state in + if let state = state { + peerIds = Array(state.selectedPeerIndices.keys) + } + return state + } + self._result.set(.single(peerIds)) + } +} diff --git a/TelegramUI/ContactMultiselectionControllerNode.swift b/TelegramUI/ContactMultiselectionControllerNode.swift new file mode 100644 index 0000000000..5888004d4a --- /dev/null +++ b/TelegramUI/ContactMultiselectionControllerNode.swift @@ -0,0 +1,55 @@ +import Display +import AsyncDisplayKit +import UIKit +import Postbox +import TelegramCore + +final class ContactMultiselectionControllerNode: ASDisplayNode { + let contactListNode: ContactListNode + let tokenListNode: EditableTokenListNode + + private let account: Account + private var searchDisplayController: SearchDisplayController? + + private var containerLayout: (ContainerViewLayout, CGFloat)? + + var requestDeactivateSearch: (() -> Void)? + var requestOpenPeerFromSearch: ((PeerId) -> Void)? + + var editableTokens: [EditableTokenListToken] = [] + + init(account: Account) { + self.account = account + self.contactListNode = ContactListNode(account: account, presentation: .natural(displaySearch: false, options: []), selectionState: ContactListNodeGroupSelectionState()) + self.tokenListNode = EditableTokenListNode() + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = UIColor.white + + self.addSubnode(self.contactListNode) + self.addSubnode(self.tokenListNode) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (layout, navigationBarHeight) + + var insets = layout.insets(options: [.input]) + insets.top += navigationBarHeight + + let tokenListHeight = self.tokenListNode.updateLayout(tokens: self.editableTokens, width: layout.size.width, transition: transition) + transition.updateFrame(node: self.tokenListNode, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top), size: CGSize(width: layout.size.width, height: tokenListHeight))) + + insets.top += tokenListHeight + + self.contactListNode.containerLayoutUpdated(ContainerViewLayout(size: layout.size, intrinsicInsets: insets, statusBarHeight: layout.statusBarHeight, inputHeight: layout.inputHeight), transition: transition) + + self.contactListNode.frame = CGRect(origin: CGPoint(), size: layout.size) + + if let searchDisplayController = self.searchDisplayController { + searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + } + } +} diff --git a/TelegramUI/ContactsController.swift b/TelegramUI/ContactsController.swift index 7af356aa2c..ce2d95809a 100644 --- a/TelegramUI/ContactsController.swift +++ b/TelegramUI/ContactsController.swift @@ -5,207 +5,8 @@ import Postbox import SwiftSignalKit import TelegramCore -private enum ContactsControllerEntryId: Hashable { - case search - case vcard - case peerId(Int64) - - var hashValue: Int { - switch self { - case .search: - return 0 - case .vcard: - return 1 - case let .peerId(peerId): - return peerId.hashValue - } - } -} - -private func <(lhs: ContactsControllerEntryId, rhs: ContactsControllerEntryId) -> Bool { - return lhs.hashValue < rhs.hashValue -} - -private func ==(lhs: ContactsControllerEntryId, rhs: ContactsControllerEntryId) -> Bool { - switch lhs { - case .search: - switch rhs { - case .search: - return true - default: - return false - } - case .vcard: - switch rhs { - case .vcard: - return true - default: - return false - } - case let .peerId(lhsId): - switch rhs { - case let .peerId(rhsId): - return lhsId == rhsId - default: - return false - } - } -} - -private enum ContactsEntry: Comparable, Identifiable { - case search - case vcard(Peer) - case peer(Peer, PeerPresence?) - - var stableId: ContactsControllerEntryId { - switch self { - case .search: - return .search - case .vcard: - return .vcard - case let .peer(peer, _): - return .peerId(peer.id.toInt64()) - } - } - - func item(account: Account, index: PeerNameIndex, interaction: ContactsControllerInteraction) -> ListViewItem { - switch self { - case .search: - return ChatListSearchItem(placeholder: "Search contacts", activate: { - interaction.activateSearch() - }) - case let .vcard(peer): - return ContactsVCardItem(account: account, peer: peer, action: { peer in - interaction.openPeer(peer.id) - }) - case let .peer(peer, presence): - let status: ContactsPeerItemStatus - if let presence = presence { - status = .presence(presence) - } else { - status = .none - } - return ContactsPeerItem(account: account, peer: peer, status: status, index: nil, header: nil, action: { _ in - interaction.openPeer(peer.id) - }) - } - } -} - -private func ==(lhs: ContactsEntry, rhs: ContactsEntry) -> Bool { - switch lhs { - case .search: - switch rhs { - case .search: - return true - default: - return false - } - case let .vcard(lhsPeer): - switch rhs { - case let .vcard(rhsPeer): - return lhsPeer.id == rhsPeer.id - default: - return false - } - case let .peer(lhsPeer, lhsPresence): - switch rhs { - case let .peer(rhsPeer, rhsPresence): - if lhsPeer.id != rhsPeer.id { - return false - } - if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence { - if !lhsPresence.isEqual(to: rhsPresence) { - return false - } - } else if (lhsPresence != nil) != (rhsPresence != nil) { - return false - } - return true - default: - return false - } - } -} - -private func <(lhs: ContactsEntry, rhs: ContactsEntry) -> Bool { - switch lhs { - case .search: - return true - case .vcard: - switch rhs { - case .search, .vcard: - return false - case .peer: - return true - } - case let .peer(lhsPeer, lhsPresence): - switch rhs { - case .search: - return false - case .vcard: - return false - case let .peer(rhsPeer, rhsPresence): - if let lhsPresence = lhsPresence as? TelegramUserPresence, let rhsPresence = rhsPresence as? TelegramUserPresence { - if lhsPresence.status < rhsPresence.status { - return false - } else if lhsPresence.status > rhsPresence.status { - return true - } - } else if let _ = lhsPresence { - return true - } else if let _ = rhsPresence { - return false - } - return lhsPeer.id < rhsPeer.id - } - } -} - -private func contactListEntries(_ view: ContactPeersView) -> [ContactsEntry] { - var entries: [ContactsEntry] = [] - entries.append(.search) - if let peer = view.accountPeer { - entries.append(.vcard(peer)) - } - for peer in view.peers { - entries.append(.peer(peer, view.peerPresences[peer.id])) - } - entries.sort() - return entries -} - -private struct ContactsListTransition { - let deletions: [ListViewDeleteItem] - let insertions: [ListViewInsertItem] - let updates: [ListViewUpdateItem] -} - -private final class ContactsControllerInteraction { - let openPeer: (PeerId) -> Void - let activateSearch: () -> Void - - init(openPeer: @escaping (PeerId) -> Void, activateSearch: @escaping () -> Void) { - self.openPeer = openPeer - self.activateSearch = activateSearch - } -} - -private func preparedContactsListTransition(account: Account, index: PeerNameIndex, from fromEntries: [ContactsEntry], to toEntries: [ContactsEntry], interaction: ContactsControllerInteraction) -> ContactsListTransition { - let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) - - let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, index: index, interaction: interaction), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, index: index, interaction: interaction), directionHint: nil) } - - return ContactsListTransition(deletions: deletions, insertions: insertions, updates: updates) -} - public class ContactsController: ViewController { - private let queue = Queue() - private let account: Account - private let transitionDisposable = MetaDisposable() private var contactsNode: ContactsControllerNode { return self.displayNode as! ContactsControllerNode @@ -217,9 +18,6 @@ public class ContactsController: ViewController { override public var ready: Promise { return self._ready } - private var didSetReady = false - - private let previousEntries = Atomic<[ContactsEntry]?>(value: nil) public init(account: Account) { self.account = account @@ -231,9 +29,11 @@ public class ContactsController: ViewController { self.tabBarItem.image = UIImage(bundleImageName: "Chat List/Tabs/IconContacts") self.tabBarItem.selectedImage = UIImage(bundleImageName: "Chat List/Tabs/IconContactsSelected") + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + self.scrollToTop = { [weak self] in if let strongSelf = self { - strongSelf.contactsNode.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Default, directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + strongSelf.contactsNode.contactListNode.scrollToTop() } } } @@ -242,12 +42,9 @@ public class ContactsController: ViewController { fatalError("init(coder:) has not been implemented") } - deinit { - self.transitionDisposable.dispose() - } - override public func loadDisplayNode() { self.displayNode = ContactsControllerNode(account: self.account) + self._ready.set(self.contactsNode.contactListNode.ready) self.contactsNode.navigationBar = self.navigationBar @@ -261,41 +58,30 @@ public class ContactsController: ViewController { } } + self.contactsNode.contactListNode.activateSearch = { [weak self] in + self?.activateSearch() + } + + self.contactsNode.contactListNode.openPeer = { [weak self] peer in + if let strongSelf = self { + strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true) + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: peer.id)) + } + } + self.displayNodeDidLoad() } override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - let interaction = ContactsControllerInteraction(openPeer: { [weak self] peerId in - if let strongSelf = self { - strongSelf.contactsNode.listView.clearHighlightAnimated(true) - (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: peerId)) - } - }, activateSearch: { [weak self] in - self?.activateSearch() - }) - - let account = self.account - let index = self.index - let previousEntries = self.previousEntries - let transition = account.postbox.contactPeersView(index: self.index, accountPeerId: account.peerId) - |> map { view -> (ContactsListTransition, Bool, Bool) in - let entries = contactListEntries(view) - let previous = previousEntries.swap(entries) - return (preparedContactsListTransition(account: account, index: index, from: previous ?? [], to: entries, interaction: interaction), previous == nil, previous != nil) - } - |> deliverOnMainQueue - - self.transitionDisposable.set(transition.start(next: { [weak self] (transition, firstTime, animated) in - self?.enqueueTransition(transition, firstTime: firstTime, animated: animated) - })) + self.contactsNode.contactListNode.enableUpdates = true } override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - self.transitionDisposable.set(nil) + self.contactsNode.contactListNode.enableUpdates = false } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -304,24 +90,6 @@ public class ContactsController: ViewController { self.contactsNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) } - private func enqueueTransition(_ transition: ContactsListTransition, firstTime: Bool, animated: Bool) { - var options = ListViewDeleteAndInsertOptions() - if firstTime { - options.insert(.Synchronous) - options.insert(.LowLatency) - } else if animated { - options.insert(.AnimateInsertion) - } - self.contactsNode.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in - if let strongSelf = self { - if !strongSelf.didSetReady { - strongSelf.didSetReady = true - strongSelf._ready.set(.single(true)) - } - } - }) - } - private func activateSearch() { if self.displayNavigationBar { if let scrollToTop = self.scrollToTop { diff --git a/TelegramUI/ContactsControllerNode.swift b/TelegramUI/ContactsControllerNode.swift index ddb228b487..47fe5a07fe 100644 --- a/TelegramUI/ContactsControllerNode.swift +++ b/TelegramUI/ContactsControllerNode.swift @@ -5,7 +5,7 @@ import Postbox import TelegramCore final class ContactsControllerNode: ASDisplayNode { - let listView: ListView + let contactListNode: ContactListNode private let account: Account private var searchDisplayController: SearchDisplayController? @@ -19,13 +19,15 @@ final class ContactsControllerNode: ASDisplayNode { init(account: Account) { self.account = account - self.listView = ListView() + self.contactListNode = ContactListNode(account: account, presentation: .orderedByPresence(displayVCard: true)) super.init(viewBlock: { return UITracingLayerView() }, didLoad: nil) - self.addSubnode(self.listView) + self.backgroundColor = UIColor.white + + self.addSubnode(self.contactListNode) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { @@ -34,34 +36,9 @@ final class ContactsControllerNode: ASDisplayNode { var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight - self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) - self.listView.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + self.contactListNode.containerLayoutUpdated(ContainerViewLayout(size: layout.size, intrinsicInsets: insets, statusBarHeight: layout.statusBarHeight, inputHeight: layout.inputHeight), transition: transition) - var duration: Double = 0.0 - var curve: UInt = 0 - switch transition { - case .immediate: - break - case let .animated(animationDuration, animationCurve): - duration = animationDuration - switch animationCurve { - case .easeInOut: - break - case .spring: - curve = 7 - } - } - - let listViewCurve: ListViewAnimationCurve - if curve == 7 { - listViewCurve = .Spring(duration: duration) - } else { - listViewCurve = .Default - } - - let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: listViewCurve) - - self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + self.contactListNode.frame = CGRect(origin: CGPoint(), size: layout.size) if let searchDisplayController = self.searchDisplayController { searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) @@ -74,7 +51,7 @@ final class ContactsControllerNode: ASDisplayNode { } var maybePlaceholderNode: SearchBarPlaceholderNode? - self.listView.forEachItemNode { node in + self.contactListNode.listNode.forEachItemNode { node in if let node = node as? ChatListSearchItemNode { maybePlaceholderNode = node.searchBarNode } @@ -105,7 +82,7 @@ final class ContactsControllerNode: ASDisplayNode { func deactivateSearch() { if let searchDisplayController = self.searchDisplayController { var maybePlaceholderNode: SearchBarPlaceholderNode? - self.listView.forEachItemNode { node in + self.contactListNode.listNode.forEachItemNode { node in if let node = node as? ChatListSearchItemNode { maybePlaceholderNode = node.searchBarNode } diff --git a/TelegramUI/ContactsPeerItem.swift b/TelegramUI/ContactsPeerItem.swift index a99c34ea33..41965fbd58 100644 --- a/TelegramUI/ContactsPeerItem.swift +++ b/TelegramUI/ContactsPeerItem.swift @@ -10,16 +10,43 @@ private let titleFont = Font.regular(17.0) private let titleBoldFont = Font.medium(17.0) private let statusFont = Font.regular(13.0) +private let selectedImage = UIImage(bundleImageName: "Contact List/SelectionChecked")?.precomposed() +private let selectableImage = UIImage(bundleImageName: "Contact List/SelectionUnchecked")?.precomposed() + enum ContactsPeerItemStatus { case none case presence(PeerPresence) case addressName } +enum ContactsPeerItemSelection: Equatable { + case none + case selectable(selected: Bool) + + static func ==(lhs: ContactsPeerItemSelection, rhs: ContactsPeerItemSelection) -> Bool { + switch lhs { + case .none: + if case .none = rhs { + return true + } else { + return false + } + case let .selectable(selected): + if case .selectable(selected) = rhs { + return true + } else { + return false + } + } + } +} + class ContactsPeerItem: ListViewItem { let account: Account let peer: Peer? + let chatPeer: Peer? let status: ContactsPeerItemStatus + let selection: ContactsPeerItemSelection let action: (Peer) -> Void let selectable: Bool = true @@ -27,10 +54,12 @@ class ContactsPeerItem: ListViewItem { let header: ListViewItemHeader? - init(account: Account, peer: Peer?, status: ContactsPeerItemStatus, index: PeerNameIndex?, header: ListViewItemHeader?, action: @escaping (Peer) -> Void) { + init(account: Account, peer: Peer?, chatPeer: Peer?, status: ContactsPeerItemStatus, selection: ContactsPeerItemSelection, index: PeerNameIndex?, header: ListViewItemHeader?, action: @escaping (Peer) -> Void) { self.account = account self.peer = peer + self.chatPeer = chatPeer self.status = status + self.selection = selection self.action = action self.header = header @@ -143,6 +172,7 @@ class ContactsPeerItemNode: ListViewItemNode { private let avatarNode: AvatarNode private let titleNode: TextNode private let statusNode: TextNode + private var selectionNode: ASImageNode? private var avatarState: (Account, Peer?)? @@ -179,27 +209,27 @@ class ContactsPeerItemNode: ListViewItemNode { self.peerPresenceManager = PeerPresenceStatusManager(update: { [weak self] in if let strongSelf = self, let layoutParams = strongSelf.layoutParams { let (_, apply) = strongSelf.asyncLayout()(layoutParams.0, layoutParams.1, layoutParams.2, layoutParams.3, layoutParams.4) - apply() + let _ = apply() } }) } override func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { if let (item, _, _, _, _) = self.layoutParams { - let (first, last, firstWithHeader) = ContactsPeerItem.mergeType(item: item as! ContactsPeerItem, previousItem: previousItem, nextItem: nextItem) + let (first, last, firstWithHeader) = ContactsPeerItem.mergeType(item: item, previousItem: previousItem, nextItem: nextItem) self.layoutParams = (item, width, first, last, firstWithHeader) let makeLayout = self.asyncLayout() let (nodeLayout, nodeApply) = makeLayout(item, width, first, last, firstWithHeader) self.contentSize = nodeLayout.contentSize self.insets = nodeLayout.insets - nodeApply() + let _ = nodeApply() } } override func setHighlighted(_ highlighted: Bool, animated: Bool) { super.setHighlighted(highlighted, animated: animated) - if highlighted { + if highlighted && self.selectionNode == nil { self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) @@ -225,11 +255,32 @@ class ContactsPeerItemNode: ListViewItemNode { func asyncLayout() -> (_ item: ContactsPeerItem, _ width: CGFloat, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, () -> (Signal?, () -> Void)) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeStatusLayout = TextNode.asyncLayout(self.statusNode) + let currentSelectionNode = self.selectionNode return { [weak self] item, width, first, last, firstWithHeader in - let leftInset: CGFloat = 65.0 + var leftInset: CGFloat = 65.0 let rightInset: CGFloat = 10.0 + let updatedSelectionNode: ASImageNode? + var updatedSelectionImage: UIImage? + switch item.selection { + case .none: + updatedSelectionNode = nil + case let .selectable(selected): + leftInset += 28.0 + + let selectionNode: ASImageNode + if let current = currentSelectionNode { + selectionNode = current + updatedSelectionNode = selectionNode + } else { + selectionNode = ASImageNode() + updatedSelectionNode = selectionNode + } + updatedSelectionImage = selected ? selectedImage : selectableImage + } + + var titleAttributedString: NSAttributedString? var statusAttributedString: NSAttributedString? var userPresence: TelegramUserPresence? @@ -295,7 +346,7 @@ class ContactsPeerItemNode: ListViewItemNode { if let strongSelf = strongSelf { strongSelf.layoutParams = (item, width, first, last, firstWithHeader) - strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 14.0, y: 4.0), size: CGSize(width: 40.0, height: 40.0)) + strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: leftInset - 51.0, y: 4.0), size: CGSize(width: 40.0, height: 40.0)) let _ = titleApply() strongSelf.titleNode.frame = titleFrame @@ -303,10 +354,27 @@ class ContactsPeerItemNode: ListViewItemNode { let _ = statusApply() strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 25.0), size: statusLayout.size) - let topHighlightInset: CGFloat = first ? 0.0 : separatorHeight + if let updatedSelectionNode = updatedSelectionNode { + if strongSelf.selectionNode !== updatedSelectionNode { + strongSelf.selectionNode?.removeFromSupernode() + strongSelf.selectionNode = updatedSelectionNode + strongSelf.addSubnode(updatedSelectionNode) + } + if updatedSelectionImage !== updatedSelectionNode.image { + updatedSelectionNode.image = updatedSelectionImage + } + if let updatedSelectionImage = updatedSelectionImage { + updatedSelectionNode.frame = CGRect(origin: CGPoint(x: 10.0, y: floor((nodeLayout.contentSize.height - updatedSelectionImage.size.height) / 2.0)), size: updatedSelectionImage.size) + } + } else if let selectionNode = strongSelf.selectionNode { + selectionNode.removeFromSupernode() + strongSelf.selectionNode = nil + } + + let topHighlightInset: CGFloat = (first || !nodeLayout.insets.top.isZero) ? 0.0 : separatorHeight strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: nodeLayout.contentSize.width, height: nodeLayout.contentSize.height)) strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - topHighlightInset), size: CGSize(width: nodeLayout.size.width, height: nodeLayout.size.height + topHighlightInset)) - strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: 65.0, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: max(0.0, nodeLayout.size.width - 65.0), height: separatorHeight)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: max(0.0, nodeLayout.size.width - 65.0), height: separatorHeight)) strongSelf.separatorNode.isHidden = last if let userPresence = userPresence { diff --git a/TelegramUI/ContactsSearchContainerNode.swift b/TelegramUI/ContactsSearchContainerNode.swift index 781bb1bb18..c0d34f01ba 100644 --- a/TelegramUI/ContactsSearchContainerNode.swift +++ b/TelegramUI/ContactsSearchContainerNode.swift @@ -55,7 +55,7 @@ final class ContactsSearchContainerNode: SearchDisplayControllerContentNode { for item in items { switch item { case let .peer(peer): - listItems.append(ContactsPeerItem(account: account, peer: peer, status: .none, index: nil, header: nil, action: { [weak self] peer in + listItems.append(ContactsPeerItem(account: account, peer: peer, chatPeer: peer, status: .none, selection: .none, index: nil, header: nil, action: { [weak self] peer in if let openPeer = self?.openPeer { self?.listNode.clearHighlightAnimated(true) openPeer(peer.id) diff --git a/TelegramUI/CreateGroupController.swift b/TelegramUI/CreateGroupController.swift new file mode 100644 index 0000000000..cbd2f80dda --- /dev/null +++ b/TelegramUI/CreateGroupController.swift @@ -0,0 +1,219 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private struct CreateGroupArguments { + let account: Account + + let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void + let done: () -> Void +} + +private enum CreateGroupSection: Int32 { + case info + case members +} + +private enum CreateGroupEntry: ItemListNodeEntry { + case groupInfo(Peer?, ItemListAvatarAndNameInfoItemState) + case setProfilePhoto + + case member(Int32, Peer, PeerPresence?) + + var section: ItemListSectionId { + switch self { + case .groupInfo, .setProfilePhoto: + return CreateGroupSection.info.rawValue + case .member: + return CreateGroupSection.members.rawValue + } + } + + var stableId: Int32 { + switch self { + case .groupInfo: + return 0 + case .setProfilePhoto: + return 1 + case let .member(index, _, _): + return 2 + index + } + } + + static func ==(lhs: CreateGroupEntry, rhs: CreateGroupEntry) -> Bool { + switch lhs { + case let .groupInfo(lhsPeer, lhsEditingState): + if case let .groupInfo(rhsPeer, rhsEditingState) = rhs { + if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + } else if (lhsPeer != nil) != (rhsPeer != nil) { + return false + } + if lhsEditingState != rhsEditingState { + return false + } + return true + } else { + return false + } + case .setProfilePhoto: + if case .setProfilePhoto = rhs { + return true + } else { + return false + } + case let .member(lhsIndex, lhsPeer, lhsPresence): + if case let .member(rhsIndex, rhsPeer, rhsPresence) = rhs { + if lhsIndex != rhsIndex { + return false + } + if !lhsPeer.isEqual(rhsPeer) { + return false + } + + if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence { + if !lhsPresence.isEqual(to: rhsPresence) { + return false + } + } else if (lhsPresence != nil) != (rhsPresence != nil) { + return false + } + return true + } else { + return false + } + } + } + + static func <(lhs: CreateGroupEntry, rhs: CreateGroupEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: CreateGroupArguments) -> ListViewItem { + switch self { + case let .groupInfo(peer, state): + return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, cachedData: nil, state: state, sectionId: ItemListSectionId(self.section), style: .blocks, editingNameUpdated: { editingName in + arguments.updateEditingName(editingName) + }) + 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, label: nil, sectionId: self.section, action: nil) + } + } +} + +private struct CreateGroupState: Equatable { + let editingName: ItemListAvatarAndNameInfoItemName + + static func ==(lhs: CreateGroupState, rhs: CreateGroupState) -> Bool { + if lhs.editingName != rhs.editingName { + return false + } + + return true + } +} + +private func createGroupEntries(state: CreateGroupState, peerIds: [PeerId], view: MultiplePeersView) -> [CreateGroupEntry] { + var entries: [CreateGroupEntry] = [] + + let groupInfoState = ItemListAvatarAndNameInfoItemState(editingName: state.editingName, updatingName: nil) + + let peer = TelegramGroup(id: PeerId(namespace: 100, id: 0), title: "", photo: [], participantCount: 0, role: .creator, membership: .Member, flags: [], migrationReference: nil, creationDate: 0, version: 0) + + entries.append(.groupInfo(peer, groupInfoState)) + entries.append(.setProfilePhoto) + + var peers: [Peer] = [] + for peerId in peerIds { + if let peer = view.peers[peerId] { + peers.append(peer) + } + } + + peers.sort(by: { lhs, rhs in + let lhsPresence = view.presences[lhs.id] as? TelegramUserPresence + let rhsPresence = view.presences[rhs.id] as? TelegramUserPresence + if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence { + if lhsPresence.status < rhsPresence.status { + return false + } else if lhsPresence.status > rhsPresence.status { + return true + } else { + return lhs.id < rhs.id + } + } else if let _ = lhsPresence { + return true + } else if let _ = rhsPresence { + return false + } else { + return lhs.id < rhs.id + } + }) + + for i in 0 ..< peers.count { + entries.append(.member(Int32(i), peers[i], view.presences[peers[i].id])) + } + + return entries +} + +public func createGroupController(account: Account, peerIds: [PeerId]) -> ViewController { + let initialState = CreateGroupState(editingName: .title(title: "")) + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((CreateGroupState) -> CreateGroupState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var replaceControllerImpl: ((ViewController) -> Void)? + + let actionsDisposable = DisposableSet() + + let arguments = CreateGroupArguments(account: account, updateEditingName: { editingName in + updateState { _ in + return CreateGroupState(editingName: editingName) + } + }, done: { + let title = stateValue.with { state -> String in + return state.editingName.composedTitle + } + + if !title.isEmpty { + actionsDisposable.add((createGroup(account: account, title: title, peerIds: peerIds) |> deliverOnMainQueue).start(next: { peerId in + if let peerId = peerId { + let controller = ChatController(account: account, peerId: peerId) + replaceControllerImpl?(controller) + } + })) + } + }) + + let signal = combineLatest(statePromise.get(), account.postbox.multiplePeersView(peerIds)) + |> map { state, view -> (ItemListControllerState, (ItemListNodeState, CreateGroupEntry.ItemGenerationArguments)) in + + let rightNavigationButton = ItemListNavigationButton(title: "Create", style: .bold, enabled: !state.editingName.composedTitle.isEmpty, action: { + arguments.done() + }) + + let controllerState = ItemListControllerState(title: "Create Group", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let listState = ItemListNodeState(entries: createGroupEntries(state: state, peerIds: peerIds, view: view), style: .blocks) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + replaceControllerImpl = { [weak controller] value in + (controller?.navigationController as? NavigationController)?.replaceAllButRootController(value, animated: true) + } + return controller +} diff --git a/TelegramUI/EditableTokenListNode.swift b/TelegramUI/EditableTokenListNode.swift new file mode 100644 index 0000000000..cc3179fd6f --- /dev/null +++ b/TelegramUI/EditableTokenListNode.swift @@ -0,0 +1,128 @@ +import Foundation +import AsyncDisplayKit +import Display + +struct EditableTokenListToken { + let id: AnyHashable + let title: String +} + +private final class TokenNode: ASDisplayNode { + let token: EditableTokenListToken + let titleNode: ASTextNode + + init(token: EditableTokenListToken) { + self.token = token + self.titleNode = ASTextNode() + self.titleNode.isLayerBacked = true + self.titleNode.maximumNumberOfLines = 1 + + super.init() + + self.titleNode.attributedText = NSAttributedString(string: token.title + ",", font: Font.regular(15.0), textColor: .black) + self.addSubnode(self.titleNode) + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + let titleSize = self.titleNode.measure(CGSize(width: constrainedSize.width - 8.0, height: constrainedSize.height)) + return CGSize(width: titleSize.width + 8.0, height: 26.0) + } + + override func layout() { + let titleSize = self.titleNode.calculatedSize + if titleSize.width.isZero { + return + } + self.titleNode.frame = CGRect(origin: CGPoint(x: 4.0, y: floor((self.bounds.size.height - titleSize.height) / 2.0)), size: titleSize) + } +} + +final class EditableTokenListNode: ASDisplayNode { + private let placeholderNode: ASTextNode + private var tokenNodes: [TokenNode] = [] + private let separatorNode: ASDisplayNode + + override init() { + self.placeholderNode = ASTextNode() + self.placeholderNode.isLayerBacked = true + self.placeholderNode.maximumNumberOfLines = 1 + self.placeholderNode.attributedText = NSAttributedString(string: "Whom would you like to message?", font: Font.regular(15.0), textColor: UIColor(0x8e8e92)) + + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + self.separatorNode.backgroundColor = UIColor(0xc7c6cb) + + super.init() + + self.backgroundColor = UIColor(0xf7f7f7) + self.addSubnode(self.placeholderNode) + self.addSubnode(self.separatorNode) + self.clipsToBounds = true + } + + func updateLayout(tokens: [EditableTokenListToken], width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + let validTokens = Set(tokens.map { $0.id }) + + for i in (0 ..< self.tokenNodes.count).reversed() { + let tokenNode = tokenNodes[i] + if !validTokens.contains(tokenNode.token.id) { + self.tokenNodes.remove(at: i) + transition.updateAlpha(node: tokenNode, alpha: 0.0, completion: { [weak tokenNode] _ in + tokenNode?.removeFromSupernode() + }) + } + } + + let sideInset: CGFloat = 4.0 + let verticalInset: CGFloat = 7.0 + + let placeholderSize = self.placeholderNode.measure(CGSize(width: max(1.0, width - sideInset - sideInset), height: CGFloat.greatestFiniteMagnitude)) + self.placeholderNode.frame = CGRect(origin: CGPoint(x: sideInset + 4.0, y: verticalInset + floor((26.0 - placeholderSize.height) / 2.0)), size: placeholderSize) + + transition.updateAlpha(node: self.placeholderNode, alpha: tokens.isEmpty ? 1.0 : 0.0) + + var currentOffset = CGPoint(x: sideInset, y: verticalInset) + for token in tokens { + var currentNode: TokenNode? + for node in self.tokenNodes { + if node.token.id == token.id { + currentNode = node + break + } + } + let tokenNode: TokenNode + var animateIn = false + if let currentNode = currentNode { + tokenNode = currentNode + } else { + tokenNode = TokenNode(token: token) + self.tokenNodes.append(tokenNode) + self.addSubnode(tokenNode) + animateIn = true + } + + let tokenSize = tokenNode.measure(CGSize(width: max(1.0, width - sideInset - sideInset), height: CGFloat.greatestFiniteMagnitude)) + if tokenSize.width + currentOffset.x >= width - sideInset { + currentOffset.x = sideInset + currentOffset.y += tokenSize.height + } + let tokenFrame = CGRect(origin: CGPoint(x: currentOffset.x, y: currentOffset.y), size: tokenSize) + currentOffset.x += tokenSize.width + + if animateIn { + tokenNode.frame = tokenFrame + tokenNode.alpha = 0.0 + transition.updateAlpha(node: tokenNode, alpha: 1.0) + } else { + transition.updateFrame(node: tokenNode, frame: tokenFrame) + } + } + + let nodeHeight = currentOffset.y + 28.0 + verticalInset + + let separatorHeight = UIScreenPixel + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: nodeHeight - separatorHeight), size: CGSize(width: width, height: separatorHeight))) + + return nodeHeight + } +} diff --git a/TelegramUI/FFMpegMediaFrameSourceContext.swift b/TelegramUI/FFMpegMediaFrameSourceContext.swift index 84e55ba13a..3027b3dc00 100644 --- a/TelegramUI/FFMpegMediaFrameSourceContext.swift +++ b/TelegramUI/FFMpegMediaFrameSourceContext.swift @@ -53,16 +53,33 @@ private func readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: Unsa let resourceSize: Int = resource.size ?? 0 let readCount = min(resourceSize - context.readingOffset, Int(bufferSize)) - let data = postbox.mediaBox.resourceData(resource, size: resourceSize, in: context.readingOffset ..< (context.readingOffset + readCount), mode: .complete) var fetchedData: Data? - let semaphore = DispatchSemaphore(value: 0) - let _ = data.start(next: { data in - if data.count == readCount { - fetchedData = data + + if resource.streamable { + let data: Signal + data = postbox.mediaBox.resourceData(resource, size: resourceSize, in: context.readingOffset ..< (context.readingOffset + readCount), mode: .complete) + let semaphore = DispatchSemaphore(value: 0) + let _ = data.start(next: { data in + if data.count == readCount { + fetchedData = data + semaphore.signal() + } + }) + semaphore.wait() + } else { + let data = postbox.mediaBox.resourceData(resource, pathExtension: nil, complete: true) + let range = context.readingOffset ..< (context.readingOffset + readCount) + 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)) + } + } semaphore.signal() - } - }) - semaphore.wait() + }) + semaphore.wait() + } if let fetchedData = fetchedData { fetchedData.withUnsafeBytes { (bytes: UnsafePointer) -> Void in memcpy(buffer, bytes, fetchedData.count) @@ -94,8 +111,14 @@ private func seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whe if context.readingOffset >= resourceSize { context.fetchedDataDisposable.set(nil) + context.requestedCompleteFetch = false } else { - context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: context.readingOffset ..< resourceSize).start()) + if resource.streamable { + context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: context.readingOffset ..< resourceSize).start()) + } else if !context.requestedCompleteFetch { + context.requestedCompleteFetch = true + context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResource(resource).start()) + } } } } @@ -116,6 +139,7 @@ final class FFMpegMediaFrameSourceContext: NSObject { fileprivate var requestedDataOffset: Int? fileprivate let fetchedDataDisposable = MetaDisposable() + fileprivate var requestedCompleteFetch = false fileprivate var readingError = false @@ -144,7 +168,12 @@ final class FFMpegMediaFrameSourceContext: NSObject { let resourceSize: Int = resource.size ?? 0 - self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: 0 ..< resourceSize).start()) + if resource.streamable { + self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: 0 ..< resourceSize).start()) + } else if !self.requestedCompleteFetch { + self.requestedCompleteFetch = true + self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResource(resource).start()) + } var avFormatContextRef = avformat_alloc_context() guard let avFormatContext = avFormatContextRef else { diff --git a/TelegramUI/FileMediaResourceStatus.swift b/TelegramUI/FileMediaResourceStatus.swift index b1aea73bb7..53b0b3e898 100644 --- a/TelegramUI/FileMediaResourceStatus.swift +++ b/TelegramUI/FileMediaResourceStatus.swift @@ -34,7 +34,7 @@ func fileMediaResourceStatus(account: Account, file: TelegramMediaFile, message: playbackStatus = .single(nil) } - if message.flags.contains(.Unsent) && !message.flags.contains(.Failed) { + if message.flags.isSending { return combineLatest(chatMessageFileStatus(account: account, file: file), account.pendingMessageManager.pendingMessageStatus(message.id), playbackStatus) |> map { resourceStatus, pendingStatus, playbackStatus -> FileMediaResourceStatus in if let playbackStatus = playbackStatus { diff --git a/TelegramUI/GalleryController.swift b/TelegramUI/GalleryController.swift index d6651e88e9..475c859b20 100644 --- a/TelegramUI/GalleryController.swift +++ b/TelegramUI/GalleryController.swift @@ -47,7 +47,7 @@ private func mediaForMessage(message: Message) -> Media? { return nil } -private func itemForEntry(account: Account, entry: MessageHistoryEntry) -> GalleryItem { +func galleryItemForEntry(account: Account, entry: MessageHistoryEntry) -> GalleryItem { switch entry { case let .MessageEntry(message, _, location): if let media = mediaForMessage(message: message) { @@ -181,7 +181,7 @@ class GalleryController: ViewController { } } if strongSelf.isViewLoaded { - strongSelf.galleryNode.pager.replaceItems(strongSelf.entries.map({ itemForEntry(account: account, entry: $0) }), centralItemIndex: strongSelf.centralEntryIndex) + strongSelf.galleryNode.pager.replaceItems(strongSelf.entries.map({ galleryItemForEntry(account: account, entry: $0) }), centralItemIndex: strongSelf.centralEntryIndex) let ready = strongSelf.galleryNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak strongSelf] _ in strongSelf?.didSetReady = true @@ -286,7 +286,7 @@ class GalleryController: ViewController { self?.presentingViewController?.dismiss(animated: false, completion: nil) } - self.galleryNode.pager.replaceItems(self.entries.map({ itemForEntry(account: self.account, entry: $0) }), centralItemIndex: self.centralEntryIndex) + self.galleryNode.pager.replaceItems(self.entries.map({ galleryItemForEntry(account: self.account, entry: $0) }), centralItemIndex: self.centralEntryIndex) self.galleryNode.pager.centralItemIndexUpdated = { [weak self] index in if let strongSelf = self { diff --git a/TelegramUI/GroupInfoEntries.swift b/TelegramUI/GroupInfoEntries.swift index 30d27b4f91..a7889dfc11 100644 --- a/TelegramUI/GroupInfoEntries.swift +++ b/TelegramUI/GroupInfoEntries.swift @@ -6,26 +6,14 @@ import Display private let addMemberPlusIcon = UIImage(bundleImageName: "Peer Info/PeerItemPlusIcon")?.precomposed() -private enum GroupInfoSection: UInt32, PeerInfoSection { +private enum GroupInfoSection: ItemListSectionId { case info case about case sharedMediaAndNotifications + case infoManagement + case memberManagement case members case leave - - func isEqual(to: PeerInfoSection) -> Bool { - guard let section = to as? GroupInfoSection else { - return false - } - return section == self - } - - func isOrderedBefore(_ section: PeerInfoSection) -> Bool { - guard let section = section as? GroupInfoSection else { - return false - } - return self.rawValue < section.rawValue - } } enum GroupInfoMemberStatus { @@ -50,29 +38,38 @@ private struct GroupPeerEntryStableId: PeerInfoEntryStableId { } enum GroupInfoEntry: PeerInfoEntry { - case info(peer: Peer?, cachedData: CachedPeerData?) + case info(peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState) case setGroupPhoto case aboutHeader case about(text: String) case sharedMedia case notifications(settings: PeerNotificationSettings?) + case groupTypeSetup(isPublic: Bool) + case groupDescriptionSetup(text: String) + case groupManagementInfoLabel(text: String) + case membersAdmins(count: Int) + case membersBlacklist(count: Int) case usersHeader case addMember case member(index: Int, peerId: PeerId, peer: Peer?, presence: PeerPresence?, memberStatus: GroupInfoMemberStatus) case leave - var section: PeerInfoSection { + var section: ItemListSectionId { switch self { case .info, .setGroupPhoto: - return GroupInfoSection.info + return GroupInfoSection.info.rawValue case .aboutHeader, .about: - return GroupInfoSection.about + return GroupInfoSection.about.rawValue case .sharedMedia, .notifications: - return GroupInfoSection.sharedMediaAndNotifications + return GroupInfoSection.sharedMediaAndNotifications.rawValue + case .groupTypeSetup, .groupDescriptionSetup, .groupManagementInfoLabel: + return GroupInfoSection.infoManagement.rawValue + case .membersAdmins, .membersBlacklist: + return GroupInfoSection.memberManagement.rawValue case .usersHeader, .addMember, .member: - return GroupInfoSection.members + return GroupInfoSection.members.rawValue case .leave: - return GroupInfoSection.leave + return GroupInfoSection.leave.rawValue } } @@ -82,27 +79,29 @@ enum GroupInfoEntry: PeerInfoEntry { } switch self { - case let .info(lhsPeer, lhsCachedData): - switch entry { - case let .info(rhsPeer, rhsCachedData): - if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { - if !lhsPeer.isEqual(rhsPeer) { + case let .info(lhsPeer, lhsCachedData, lhsState): + if case let .info(rhsPeer, rhsCachedData, rhsState) = entry { + if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + } else if (lhsPeer == nil) != (rhsPeer != nil) { return false } - } else if (lhsPeer == nil) != (rhsPeer != nil) { - return false - } - if let lhsCachedData = lhsCachedData, let rhsCachedData = rhsCachedData { - if !lhsCachedData.isEqual(to: rhsCachedData) { + if let lhsCachedData = lhsCachedData, let rhsCachedData = rhsCachedData { + if !lhsCachedData.isEqual(to: rhsCachedData) { + return false + } + } else if (rhsCachedData == nil) != (rhsCachedData != nil) { return false } - } else if (rhsCachedData == nil) != (rhsCachedData != nil) { + if lhsState != lhsState { + return false + } + return true + } else { return false } - return true - default: - return false - } case .setGroupPhoto: if case .setGroupPhoto = entry { return true @@ -141,6 +140,36 @@ enum GroupInfoEntry: PeerInfoEntry { default: return false } + case let .groupTypeSetup(isPublic): + if case .groupTypeSetup(isPublic) = entry { + return true + } else { + return false + } + case let .groupDescriptionSetup(text): + if case .groupDescriptionSetup(text) = entry { + return true + } else { + return false + } + case let .groupManagementInfoLabel(text): + if case .groupManagementInfoLabel(text) = entry { + return true + } else { + return false + } + case let .membersAdmins(lhsCount): + if case let .membersAdmins(rhsCount) = entry, lhsCount == rhsCount { + return true + } else { + return false + } + case let .membersBlacklist(lhsCount): + if case let .membersBlacklist(rhsCount) = entry, lhsCount == rhsCount { + return true + } else { + return false + } case .usersHeader: if case .usersHeader = entry { return true @@ -214,12 +243,22 @@ enum GroupInfoEntry: PeerInfoEntry { return 4 case .sharedMedia: return 5 - case .usersHeader: + case .groupTypeSetup: return 6 - case .addMember: + case .groupDescriptionSetup: return 7 + case .groupManagementInfoLabel: + return 8 + case .membersAdmins: + return 9 + case .membersBlacklist: + return 10 + case .usersHeader: + return 11 + case .addMember: + return 12 case let .member(index, _, _, _, _): - return 10 + index + return 20 + index case .leave: return 1000000 } @@ -235,10 +274,12 @@ enum GroupInfoEntry: PeerInfoEntry { func item(account: Account, interaction: PeerInfoControllerInteraction) -> ListViewItem { switch self { - case let .info(peer, cachedData): - return PeerInfoAvatarAndNameItem(account: account, peer: peer, cachedData: cachedData, editingState: nil, sectionId: self.section.rawValue, style: .blocks) + case let .info(peer, cachedData, state): + return ItemListAvatarAndNameInfoItem(account: account, peer: peer, cachedData: cachedData, state: state, sectionId: self.section, style: .blocks, editingNameUpdated: { editingName in + + }) case .setGroupPhoto: - return PeerInfoActionItem(title: "Set Group Photo", kind: .generic, alignment: .natural, sectionId: self.section.rawValue, style: .blocks, action: { + return ItemListActionItem(title: "Set Group Photo", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { }) case let .notifications(settings): let label: String @@ -247,15 +288,38 @@ enum GroupInfoEntry: PeerInfoEntry { } else { label = "Enabled" } - return PeerInfoDisclosureItem(title: "Notifications", label: label, sectionId: self.section.rawValue, style: .blocks, action: { + return ItemListDisclosureItem(title: "Notifications", label: label, sectionId: self.section, style: .blocks, action: { interaction.changeNotificationMuteSettings() }) case .sharedMedia: - return PeerInfoDisclosureItem(title: "Shared Media", label: "", sectionId: self.section.rawValue, style: .blocks, action: { + return ItemListDisclosureItem(title: "Shared Media", label: "", sectionId: self.section, style: .blocks, action: { interaction.openSharedMedia() }) case .addMember: - return PeerInfoPeerActionItem(icon: addMemberPlusIcon, title: "Add Member", sectionId: self.section.rawValue, action: { + return ItemListPeerActionItem(icon: addMemberPlusIcon, title: "Add Member", sectionId: self.section, action: { + + }) + case let .groupTypeSetup(isPublic): + return ItemListDisclosureItem(title: "Group Type", label: isPublic ? "Public" : "Private", sectionId: self.section, style: .blocks, action: { + + }) + case let .groupDescriptionSetup(text): + return ItemListMultilineInputItem(text: text, sectionId: self.section, textUpdated: { updatedText in + interaction.updateState { state in + if let state = state as? GroupInfoState, let editingState = state.editingState { + return state.withUpdatedEditingState(editingState.withUpdatedEditingDescriptionText(updatedText)) + } + return state + } + }, action: { + + }) + case let .membersAdmins(count): + return ItemListDisclosureItem(title: "Admins", label: "\(count)", sectionId: self.section, style: .blocks, action: { + + }) + case let .membersBlacklist(count): + return ItemListDisclosureItem(title: "Blacklist", label: "\(count)", sectionId: self.section, style: .blocks, action: { }) case let .member(_, _, peer, presence, memberStatus): @@ -266,13 +330,13 @@ enum GroupInfoEntry: PeerInfoEntry { case .member: label = nil } - return PeerInfoPeerItem(account: account, peer: peer, presence: presence, label: label, sectionId: self.section.rawValue, action: { + return ItemListPeerItem(account: account, peer: peer, presence: presence, label: label, sectionId: self.section, action: { if let peer = peer { interaction.openPeerInfo(peer.id) } }) case .leave: - return PeerInfoActionItem(title: "Delete and Exit", kind: .destructive, alignment: .center, sectionId: self.section.rawValue, style: .blocks, action: { + return ItemListActionItem(title: "Delete and Exit", kind: .destructive, alignment: .center, sectionId: self.section, style: .blocks, action: { }) default: preconditionFailure() @@ -280,9 +344,58 @@ enum GroupInfoEntry: PeerInfoEntry { } } -func groupInfoEntries(view: PeerView) -> PeerInfoEntries { +private struct GroupInfoState: PeerInfoState { + let editingState: GroupInfoEditingState? + let updatingName: ItemListAvatarAndNameInfoItemName? + + func isEqual(to: PeerInfoState) -> Bool { + if let to = to as? GroupInfoState { + if self.editingState != to.editingState { + return false + } + if self.updatingName != to.updatingName { + return false + } + return true + } else { + return false + } + } + + func withUpdatedEditingState(_ editingState: GroupInfoEditingState?) -> GroupInfoState { + return GroupInfoState(editingState: editingState, updatingName: self.updatingName) + } + + func withUpdatedUpdatingName(_ updatingName: ItemListAvatarAndNameInfoItemName?) -> GroupInfoState { + return GroupInfoState(editingState: self.editingState, updatingName: updatingName) + } +} + +private struct GroupInfoEditingState: Equatable { + let editingName: ItemListAvatarAndNameInfoItemName? + let editingDescriptionText: String + + func withUpdatedEditingDescriptionText(_ editingDescriptionText: String) -> GroupInfoEditingState { + return GroupInfoEditingState(editingName: self.editingName, editingDescriptionText: editingDescriptionText) + } + + static func ==(lhs: GroupInfoEditingState, rhs: GroupInfoEditingState) -> Bool { + if lhs.editingName != rhs.editingName { + return false + } + if lhs.editingDescriptionText != rhs.editingDescriptionText { + return false + } + return true + } +} + +func groupInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { var entries: [PeerInfoEntry] = [] - entries.append(GroupInfoEntry.info(peer: view.peers[view.peerId], cachedData: view.cachedData)) + if let peer = peerViewMainPeer(view) { + let infoState = ItemListAvatarAndNameInfoItemState(editingName: (state as? GroupInfoState)?.editingState?.editingName, updatingName: (state as? GroupInfoState)?.updatingName) + entries.append(GroupInfoEntry.info(peer: peer, cachedData: view.cachedData, state: infoState)) + } var highlightAdmins = false var canManageGroup = false @@ -312,8 +425,22 @@ func groupInfoEntries(view: PeerView) -> PeerInfoEntries { entries.append(GroupInfoEntry.setGroupPhoto) } - entries.append(GroupInfoEntry.notifications(settings: view.notificationSettings)) - entries.append(GroupInfoEntry.sharedMedia) + if let editingState = (state as? GroupInfoState)?.editingState { + if let cachedChannelData = view.cachedData as? CachedChannelData { + entries.append(GroupInfoEntry.groupTypeSetup(isPublic: cachedChannelData.exportedInvitation != nil)) + entries.append(GroupInfoEntry.groupDescriptionSetup(text: editingState.editingDescriptionText)) + + if let adminCount = cachedChannelData.participantsSummary.adminCount { + entries.append(GroupInfoEntry.membersAdmins(count: adminCount)) + } + if let bannedCount = cachedChannelData.participantsSummary.bannedCount { + entries.append(GroupInfoEntry.membersBlacklist(count: bannedCount)) + } + } + } else { + entries.append(GroupInfoEntry.notifications(settings: view.notificationSettings)) + entries.append(GroupInfoEntry.sharedMedia) + } if canManageGroup { entries.append(GroupInfoEntry.addMember) @@ -388,6 +515,105 @@ func groupInfoEntries(view: PeerView) -> PeerInfoEntries { entries.append(GroupInfoEntry.member(index: i, peerId: peer.id, peer: peer, presence: view.peerPresences[peer.id], memberStatus: memberStatus)) } } + } else if let cachedChannelData = view.cachedData as? CachedChannelData, let participants = cachedChannelData.topParticipants { + let sortedParticipants = participants.participants.sorted(by: { lhs, rhs in + let lhsPresence = view.peerPresences[lhs.peerId] as? TelegramUserPresence + let rhsPresence = view.peerPresences[rhs.peerId] as? TelegramUserPresence + if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence { + if lhsPresence.status < rhsPresence.status { + return false + } else if lhsPresence.status > rhsPresence.status { + return true + } + } else if let _ = lhsPresence { + return true + } else if let _ = rhsPresence { + return false + } + + switch lhs { + case .creator: + return false + case let .moderator(lhsId, _, lhsInvitedAt): + switch rhs { + case .creator: + return true + case let .moderator(rhsId, _, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + case let .editor(rhsId, _, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + case let .member(rhsId, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + } + case let .editor(lhsId, _, lhsInvitedAt): + switch rhs { + case .creator: + return true + case let .moderator(rhsId, _, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + case let .editor(rhsId, _, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + case let .member(rhsId, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + } + case let .member(lhsId, lhsInvitedAt): + switch rhs { + case .creator: + return true + case let .moderator(rhsId, _, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + case let .editor(rhsId, _, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + case let .member(rhsId, rhsInvitedAt): + if lhsInvitedAt == rhsInvitedAt { + return lhsId.id < rhsId.id + } + return lhsInvitedAt > rhsInvitedAt + } + } + return false + }) + + for i in 0 ..< sortedParticipants.count { + if let peer = view.peers[sortedParticipants[i].peerId] { + let memberStatus: GroupInfoMemberStatus + if highlightAdmins { + switch sortedParticipants[i] { + case .moderator, .editor, .creator: + memberStatus = .admin + case .member: + memberStatus = .member + } + } else { + memberStatus = .member + } + entries.append(GroupInfoEntry.member(index: i, peerId: peer.id, peer: peer, presence: view.peerPresences[peer.id], memberStatus: memberStatus)) + } + } } if let group = view.peers[view.peerId] as? TelegramGroup { @@ -399,6 +625,52 @@ func groupInfoEntries(view: PeerView) -> PeerInfoEntries { entries.append(GroupInfoEntry.leave) } } + + var leftNavigationButton: PeerInfoNavigationButton? + var rightNavigationButton: PeerInfoNavigationButton? + if canManageGroup { + if let state = state as? GroupInfoState, let _ = state.editingState { + leftNavigationButton = PeerInfoNavigationButton(title: "Cancel", action: { state in + if state == nil { + return GroupInfoState(editingState: nil, updatingName: nil) + } else if let state = state as? GroupInfoState { + return state.withUpdatedEditingState(nil) + } else { + return state + } + }) + rightNavigationButton = PeerInfoNavigationButton(title: "Done", action: { state in + if state == nil { + return GroupInfoState(editingState: nil, updatingName: nil) + } else if let state = state as? GroupInfoState { + return state.withUpdatedEditingState(nil) + } else { + return state + } + }) + } else { + var editingName: ItemListAvatarAndNameInfoItemName? + if let peer = peerViewMainPeer(view) { + editingName = ItemListAvatarAndNameInfoItemName(peer.indexName) + } + let editingDescriptionText: String + if let cachedChannelData = view.cachedData as? CachedChannelData, let about = cachedChannelData.about { + editingDescriptionText = about + } else { + editingDescriptionText = "" + } + rightNavigationButton = PeerInfoNavigationButton(title: "Edit", action: { state in + if state == nil { + return GroupInfoState(editingState: GroupInfoEditingState(editingName: editingName, editingDescriptionText: editingDescriptionText), updatingName: nil) + } else if let state = state as? GroupInfoState { + return state.withUpdatedEditingState(GroupInfoEditingState(editingName: editingName, editingDescriptionText: editingDescriptionText)) + } else { + return state + } + }) + } + } - return PeerInfoEntries(entries: entries, leftNavigationButton: nil, rightNavigationButton: nil) + + return PeerInfoEntries(entries: entries, leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) } diff --git a/TelegramUI/HashtagSearchController.swift b/TelegramUI/HashtagSearchController.swift index a6ec384053..0e0feb2160 100644 --- a/TelegramUI/HashtagSearchController.swift +++ b/TelegramUI/HashtagSearchController.swift @@ -41,23 +41,33 @@ final class HashtagSearchController: TelegramController { |> map { return $0.map({ .message($0) }) } } + let interaction = ChatListNodeInteraction(activateSearch: { + }, peerSelected: { peer in + + }, messageSelected: { [weak self] message in + if let strongSelf = self { + if let peer = message.peers[message.id.peerId] { + strongSelf.openMessageFromSearchDisposable.set((storedMessageFromSearchPeer(account: strongSelf.account, peer: peer) |> deliverOnMainQueue).start(completed: { + if let strongSelf = self { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: message.id.peerId, messageId: message.id)) + } + })) + } + strongSelf.controllerNode.listNode.clearHighlightAnimated(true) + } + }, setPeerIdWithRevealedOptions: { _ in + }, setPeerPinned: { _ in + }, setPeerMuted: { _ in + }, deletePeer: { _ in + }) + let previousSearchItems = Atomic<[ChatListSearchEntry]?>(value: nil) self.transitionDisposable = (foundMessages |> deliverOn(self.queue)).start(next: { [weak self] entries in if let strongSelf = self { let previousEntries = previousSearchItems.swap(entries) let firstTime = previousEntries == nil - let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries ?? [], displayingResults: entries != nil, account: account, enableHeaders: false, openPeer: { peer in - }, openMessage: { message in - if let peer = message.peers[message.id.peerId] { - strongSelf.openMessageFromSearchDisposable.set((storedMessageFromSearchPeer(account: strongSelf.account, peer: peer) |> deliverOnMainQueue).start(completed: { [weak strongSelf] in - if let strongSelf = strongSelf { - (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: message.id.peerId, messageId: message.id)) - } - })) - } - strongSelf.controllerNode.listNode.clearHighlightAnimated(true) - }) + let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries ?? [], displayingResults: entries != nil, account: account, enableHeaders: false, interaction: interaction) strongSelf.controllerNode.enqueueTransition(transition, firstTime: firstTime) } }) diff --git a/TelegramUI/InAppNotificationSettings.swift b/TelegramUI/InAppNotificationSettings.swift new file mode 100644 index 0000000000..0019bbaaef --- /dev/null +++ b/TelegramUI/InAppNotificationSettings.swift @@ -0,0 +1,78 @@ +import Foundation +import Postbox +import SwiftSignalKit + +struct InAppNotificationSettings: PreferencesEntry, Equatable { + let playSounds: Bool + let vibrate: Bool + let displayPreviews: Bool + + static var defaultSettings: InAppNotificationSettings { + return InAppNotificationSettings(playSounds: true, vibrate: false, displayPreviews: true) + } + + init(playSounds: Bool, vibrate: Bool, displayPreviews: Bool) { + self.playSounds = playSounds + self.vibrate = vibrate + self.displayPreviews = displayPreviews + } + + init(decoder: Decoder) { + self.playSounds = (decoder.decodeInt32ForKey("s") as Int32) != 0 + self.vibrate = (decoder.decodeInt32ForKey("v") as Int32) != 0 + self.displayPreviews = (decoder.decodeInt32ForKey("p") as Int32) != 0 + } + + func encode(_ encoder: Encoder) { + encoder.encodeInt32(self.playSounds ? 1 : 0, forKey: "s") + encoder.encodeInt32(self.vibrate ? 1 : 0, forKey: "v") + encoder.encodeInt32(self.displayPreviews ? 1 : 0, forKey: "p") + } + + func withUpdatedPlaySounds(_ playSounds: Bool) -> InAppNotificationSettings { + return InAppNotificationSettings(playSounds: playSounds, vibrate: self.vibrate, displayPreviews: self.displayPreviews) + } + + func withUpdatedVibrate(_ vibrate: Bool) -> InAppNotificationSettings { + return InAppNotificationSettings(playSounds: self.playSounds, vibrate: vibrate, displayPreviews: self.displayPreviews) + } + + func withUpdatedDisplayPreviews(_ displayPreviews: Bool) -> InAppNotificationSettings { + return InAppNotificationSettings(playSounds: self.playSounds, vibrate: self.vibrate, displayPreviews: displayPreviews) + } + + func isEqual(to: PreferencesEntry) -> Bool { + if let to = to as? InAppNotificationSettings { + return self == to + } else { + return false + } + } + + static func ==(lhs: InAppNotificationSettings, rhs: InAppNotificationSettings) -> Bool { + if lhs.playSounds != rhs.playSounds { + return false + } + if lhs.vibrate != rhs.vibrate { + return false + } + if lhs.displayPreviews != rhs.displayPreviews { + return false + } + return true + } +} + +func updateInAppNotificationSettingsInteractively(postbox: Postbox, _ f: @escaping (InAppNotificationSettings) -> InAppNotificationSettings) -> Signal { + return postbox.modify { modifier -> Void in + modifier.updatePreferencesEntry(key: ApplicationSpecificPreferencesKeys.inAppNotificationSettings, { entry in + let currentSettings: InAppNotificationSettings + if let entry = entry as? InAppNotificationSettings { + currentSettings = entry + } else { + currentSettings = InAppNotificationSettings.defaultSettings + } + return f(currentSettings) + }) + } +} diff --git a/TelegramUI/PeerInfoActionItem.swift b/TelegramUI/ItemListActionItem.swift similarity index 82% rename from TelegramUI/PeerInfoActionItem.swift rename to TelegramUI/ItemListActionItem.swift index f3ed8f896a..78f1fc1af4 100644 --- a/TelegramUI/PeerInfoActionItem.swift +++ b/TelegramUI/ItemListActionItem.swift @@ -3,25 +3,25 @@ import Display import AsyncDisplayKit import SwiftSignalKit -enum PeerInfoActionKind { +enum ItemListActionKind { case generic case destructive } -enum PeerInfoActionAlignment { +enum ItemListActionAlignment { case natural case center } -class PeerInfoActionItem: ListViewItem, PeerInfoItem { +class ItemListActionItem: ListViewItem, ItemListItem { let title: String - let kind: PeerInfoActionKind - let alignment: PeerInfoActionAlignment - let sectionId: PeerInfoItemSectionId - let style: PeerInfoListStyle + let kind: ItemListActionKind + let alignment: ItemListActionAlignment + let sectionId: ItemListSectionId + let style: ItemListStyle let action: () -> Void - init(title: String, kind: PeerInfoActionKind, alignment: PeerInfoActionAlignment, sectionId: PeerInfoItemSectionId, style: PeerInfoListStyle, action: @escaping () -> Void) { + init(title: String, kind: ItemListActionKind, alignment: ItemListActionAlignment, sectionId: ItemListSectionId, style: ItemListStyle, action: @escaping () -> Void) { self.title = title self.kind = kind self.alignment = alignment @@ -32,8 +32,8 @@ class PeerInfoActionItem: ListViewItem, PeerInfoItem { func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { - let node = PeerInfoActionItemNode() - let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let node = ItemListActionItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) node.contentSize = layout.contentSize node.insets = layout.insets @@ -45,12 +45,12 @@ class PeerInfoActionItem: ListViewItem, PeerInfoItem { } func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { - if let node = node as? PeerInfoActionItemNode { + if let node = node as? ItemListActionItemNode { Queue.mainQueue().async { let makeLayout = node.asyncLayout() async { - let (layout, apply) = makeLayout(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { completion(layout, { apply() @@ -71,7 +71,7 @@ class PeerInfoActionItem: ListViewItem, PeerInfoItem { private let titleFont = Font.regular(17.0) -class PeerInfoActionItemNode: ListViewItemNode { +class ItemListActionItemNode: ListViewItemNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode @@ -106,7 +106,7 @@ class PeerInfoActionItemNode: ListViewItemNode { self.addSubnode(self.titleNode) } - func asyncLayout() -> (_ item: PeerInfoActionItem, _ width: CGFloat, _ neighbors: PeerInfoItemNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ItemListActionItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) return { item, width, neighbors in @@ -121,24 +121,10 @@ class PeerInfoActionItemNode: ListViewItemNode { switch item.style { case .plain: contentSize = CGSize(width: width, height: 44.0) - insets = peerInfoItemNeighborsPlainInsets(neighbors) + insets = itemListNeighborsPlainInsets(neighbors) case .blocks: contentSize = CGSize(width: width, height: 44.0) - let topInset: CGFloat - switch neighbors.top { - case .sameSection, .none: - topInset = 0.0 - case .otherSection: - topInset = separatorHeight + 35.0 - } - let bottomInset: CGFloat - switch neighbors.bottom { - case .sameSection, .otherSection: - bottomInset = 0.0 - case .none: - bottomInset = separatorHeight + 35.0 - } - insets = UIEdgeInsets(top: topInset, left: 0.0, bottom: bottomInset, right: 0.0) + insets = itemListNeighborsGroupedInsets(neighbors) } let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) @@ -178,21 +164,24 @@ class PeerInfoActionItemNode: ListViewItemNode { strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) } switch neighbors.top { - case .sameSection: + case .sameSection(false): strongSelf.topStripeNode.isHidden = true - case .none, .otherSection: + default: strongSelf.topStripeNode.isHidden = false } let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat switch neighbors.bottom { - case .sameSection: + case .sameSection(false): bottomStripeInset = 16.0 - case .none, .otherSection: + bottomStripeOffset = -separatorHeight + default: bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 } strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) } switch item.alignment { diff --git a/TelegramUI/PeerInfoAvatarAndNameItem.swift b/TelegramUI/ItemListAvatarAndNameItem.swift similarity index 52% rename from TelegramUI/PeerInfoAvatarAndNameItem.swift rename to TelegramUI/ItemListAvatarAndNameItem.swift index 568091c288..c7d96aebcb 100644 --- a/TelegramUI/PeerInfoAvatarAndNameItem.swift +++ b/TelegramUI/ItemListAvatarAndNameItem.swift @@ -5,33 +5,84 @@ import Postbox import TelegramCore import SwiftSignalKit -struct PeerInfoAvatarAndNameItemEditingState: Equatable { - static func ==(lhs: PeerInfoAvatarAndNameItemEditingState, rhs: PeerInfoAvatarAndNameItemEditingState) -> Bool { +enum ItemListAvatarAndNameInfoItemName: Equatable { + case personName(firstName: String, lastName: String) + case title(title: String) + + init(_ name: PeerIndexNameRepresentation) { + switch name { + case let .personName(first, last, _): + self = .personName(firstName: first, lastName: last) + case let .title(title, _): + self = .title(title: title) + } + } + + var composedTitle: String { + switch self { + case let .personName(firstName, lastName): + return firstName + " " + lastName + case let .title(title): + return title + } + } + + static func ==(lhs: ItemListAvatarAndNameInfoItemName, rhs: ItemListAvatarAndNameInfoItemName) -> Bool { + switch lhs { + case let .personName(firstName, lastName): + if case .personName(firstName, lastName) = rhs { + return true + } else { + return false + } + case let .title(title): + if case .title(title) = rhs { + return true + } else { + return false + } + } + } +} + +struct ItemListAvatarAndNameInfoItemState: Equatable { + let editingName: ItemListAvatarAndNameInfoItemName? + let updatingName: ItemListAvatarAndNameInfoItemName? + + static func ==(lhs: ItemListAvatarAndNameInfoItemState, rhs: ItemListAvatarAndNameInfoItemState) -> Bool { + if lhs.editingName != rhs.editingName { + return false + } + if lhs.updatingName != rhs.updatingName { + return false + } return true } } -class PeerInfoAvatarAndNameItem: ListViewItem, PeerInfoItem { +class ItemListAvatarAndNameInfoItem: ListViewItem, ItemListItem { let account: Account let peer: Peer? let cachedData: CachedPeerData? - let editingState: PeerInfoAvatarAndNameItemEditingState? - let sectionId: PeerInfoItemSectionId - let style: PeerInfoListStyle + let state: ItemListAvatarAndNameInfoItemState + let sectionId: ItemListSectionId + let style: ItemListStyle + let editingNameUpdated: (ItemListAvatarAndNameInfoItemName) -> Void - init(account: Account, peer: Peer?, cachedData: CachedPeerData?, editingState: PeerInfoAvatarAndNameItemEditingState?, sectionId: PeerInfoItemSectionId, style: PeerInfoListStyle) { + init(account: Account, peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState, sectionId: ItemListSectionId, style: ItemListStyle, editingNameUpdated: @escaping (ItemListAvatarAndNameInfoItemName) -> Void) { self.account = account self.peer = peer self.cachedData = cachedData - self.editingState = editingState + self.state = state self.sectionId = sectionId self.style = style + self.editingNameUpdated = editingNameUpdated } func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { - let node = PeerInfoAvatarAndNameItemNode() - let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let node = ItemListAvatarAndNameInfoItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) node.contentSize = layout.contentSize node.insets = layout.insets @@ -43,7 +94,7 @@ class PeerInfoAvatarAndNameItem: ListViewItem, PeerInfoItem { } func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { - if let node = node as? PeerInfoAvatarAndNameItemNode { + if let node = node as? ItemListAvatarAndNameInfoItemNode { var animated = true if case .None = animation { animated = false @@ -52,7 +103,7 @@ class PeerInfoAvatarAndNameItem: ListViewItem, PeerInfoItem { let makeLayout = node.asyncLayout() async { - let (layout, apply) = makeLayout(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { completion(layout, { apply(animated) @@ -62,18 +113,12 @@ class PeerInfoAvatarAndNameItem: ListViewItem, PeerInfoItem { } } } - - var selectable: Bool = true - - func selected(listView: ListView){ - - } } private let nameFont = Font.medium(19.0) private let statusFont = Font.regular(15.0) -class PeerInfoAvatarAndNameItemNode: ListViewItemNode { +class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode @@ -83,8 +128,10 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { private let statusNode: TextNode private var inputSeparator: ASDisplayNode? - private var inputFirstField: ASEditableTextNode? - private var inputSecondField: ASEditableTextNode? + private var inputFirstField: UITextField? + private var inputSecondField: UITextField? + + private var item: ItemListAvatarAndNameInfoItem? init() { self.backgroundNode = ASDisplayNode() @@ -99,7 +146,7 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) self.bottomStripeNode.isLayerBacked = true - self.avatarNode = AvatarNode(font: Font.regular(20.0)) + self.avatarNode = AvatarNode(font: Font.regular(28.0)) self.nameNode = TextNode() self.nameNode.isLayerBacked = true @@ -118,12 +165,21 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { self.addSubnode(self.statusNode) } - func asyncLayout() -> (_ item: PeerInfoAvatarAndNameItem, _ width: CGFloat, _ neighbors: PeerInfoItemNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { + func asyncLayout() -> (_ item: ItemListAvatarAndNameInfoItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { let layoutNameNode = TextNode.asyncLayout(self.nameNode) let layoutStatusNode = TextNode.asyncLayout(self.statusNode) return { item, width, neighbors in - let (nameNodeLayout, nameNodeApply) = layoutNameNode(NSAttributedString(string: item.peer?.displayTitle ?? "", font: nameFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + let displayTitle: ItemListAvatarAndNameInfoItemName + if let updatingName = item.state.updatingName { + displayTitle = updatingName + } else if let peer = item.peer { + displayTitle = ItemListAvatarAndNameInfoItemName(peer.indexName) + } else { + displayTitle = .title(title: "") + } + + let (nameNodeLayout, nameNodeApply) = layoutNameNode(NSAttributedString(string: displayTitle.composedTitle, font: nameFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) let statusText: String let statusColor: UIColor @@ -161,7 +217,7 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { switch item.style { case .plain: contentSize = CGSize(width: width, height: 96.0) - insets = peerInfoItemNeighborsPlainInsets(neighbors) + insets = itemListNeighborsPlainInsets(neighbors) case .blocks: contentSize = CGSize(width: width, height: 92.0) let topInset: CGFloat @@ -179,6 +235,8 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { return (layout, { [weak self] animated in if let strongSelf = self { + strongSelf.item = item + let avatarOriginY: CGFloat switch item.style { case .plain: @@ -228,6 +286,22 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { let _ = nameNodeApply() let _ = statusNodeApply() + /*if let _ = item.state.updatingName { + if !strongSelf.nameNode.alpha.isEqual(to: 0.5) { + strongSelf.nameNode.alpha = 0.5 + if animated { + strongSelf.nameNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.4) + } + } + } else { + if !strongSelf.nameNode.alpha.isEqual(to: 1.0) { + strongSelf.nameNode.alpha = 1.0 + if animated { + strongSelf.nameNode.layer.animateAlpha(from: 0.5, to: 1.0, duration: 0.4) + } + } + }*/ + if let peer = item.peer { strongSelf.avatarNode.setPeer(account: item.account, peer: peer) } @@ -237,48 +311,88 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: 94.0, y: 25.0 + nameNodeLayout.size.height + 4.0), size: statusNodeLayout.size) - if let editingState = item.editingState { - if let user = item.peer as? TelegramUser { - if strongSelf.inputSeparator == nil { - let inputSeparator = ASDisplayNode() - inputSeparator.backgroundColor = UIColor(0xc8c7cc) - inputSeparator.isLayerBacked = true - strongSelf.addSubnode(inputSeparator) - strongSelf.inputSeparator = inputSeparator - } - - if strongSelf.inputFirstField == nil { - let inputFirstField = ASEditableTextNode() - inputFirstField.typingAttributes = [NSFontAttributeName: Font.regular(17.0)] - //inputFirstField.backgroundColor = UIColor.lightGray - inputFirstField.attributedPlaceholderText = NSAttributedString(string: "First Name", font: Font.regular(17.0), textColor: UIColor(0xc8c8ce)) - inputFirstField.attributedText = NSAttributedString(string: user.firstName ?? "", font: Font.regular(17.0), textColor: UIColor.black) - strongSelf.inputFirstField = inputFirstField - strongSelf.view.addSubnode(inputFirstField) - } - - if strongSelf.inputSecondField == nil { - let inputSecondField = ASEditableTextNode() - inputSecondField.typingAttributes = [NSFontAttributeName: Font.regular(17.0)] - //inputSecondField.backgroundColor = UIColor.lightGray - inputSecondField.attributedPlaceholderText = NSAttributedString(string: "Last Name", font: Font.regular(17.0), textColor: UIColor(0xc8c8ce)) - inputSecondField.attributedText = NSAttributedString(string: user.lastName ?? "", font: Font.regular(17.0), textColor: UIColor.black) - strongSelf.inputSecondField = inputSecondField - strongSelf.view.addSubnode(inputSecondField) - } - - strongSelf.inputSeparator?.frame = CGRect(origin: CGPoint(x: 100.0, y: 49.0), size: CGSize(width: width - 100.0, height: separatorHeight)) - strongSelf.inputFirstField?.frame = CGRect(origin: CGPoint(x: 111.0, y: 16.0), size: CGSize(width: width - 111.0 - 8.0, height: 30.0)) - strongSelf.inputSecondField?.frame = CGRect(origin: CGPoint(x: 111.0, y: 59.0), size: CGSize(width: width - 111.0 - 8.0, height: 30.0)) - - if animated { - strongSelf.inputSeparator?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - strongSelf.inputFirstField?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - strongSelf.inputSecondField?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - } + if let editingName = item.state.editingName { + var animateIn = false + if strongSelf.inputSeparator == nil { + animateIn = true + } + switch editingName { + case let .personName(firstName, lastName): + if strongSelf.inputSeparator == nil { + let inputSeparator = ASDisplayNode() + inputSeparator.backgroundColor = UIColor(0xc8c7cc) + inputSeparator.isLayerBacked = true + strongSelf.addSubnode(inputSeparator) + strongSelf.inputSeparator = inputSeparator + } + + if strongSelf.inputFirstField == nil { + let inputFirstField = TextFieldNodeView() + inputFirstField.typingAttributes = [NSFontAttributeName: Font.regular(17.0)] + //inputFirstField.backgroundColor = UIColor.lightGray + inputFirstField.attributedPlaceholder = NSAttributedString(string: "First Name", font: Font.regular(17.0), textColor: UIColor(0xc8c8ce)) + inputFirstField.attributedText = NSAttributedString(string: firstName, font: Font.regular(17.0), textColor: UIColor.black) + strongSelf.inputFirstField = inputFirstField + strongSelf.view.addSubview(inputFirstField) + inputFirstField.addTarget(self, action: #selector(strongSelf.textFieldDidChange(_:)), for: .editingChanged) + } else if strongSelf.inputFirstField?.text != firstName { + strongSelf.inputFirstField?.text = firstName + } + + if strongSelf.inputSecondField == nil { + let inputSecondField = TextFieldNodeView() + inputSecondField.typingAttributes = [NSFontAttributeName: Font.regular(17.0)] + //inputSecondField.backgroundColor = UIColor.lightGray + inputSecondField.attributedPlaceholder = NSAttributedString(string: "Last Name", font: Font.regular(17.0), textColor: UIColor(0xc8c8ce)) + inputSecondField.attributedText = NSAttributedString(string: lastName, font: Font.regular(17.0), textColor: UIColor.black) + strongSelf.inputSecondField = inputSecondField + strongSelf.view.addSubview(inputSecondField) + inputSecondField.addTarget(self, action: #selector(strongSelf.textFieldDidChange(_:)), for: .editingChanged) + } else if strongSelf.inputSecondField?.text != lastName { + strongSelf.inputSecondField?.text = lastName + } + + strongSelf.inputSeparator?.frame = CGRect(origin: CGPoint(x: 100.0, y: 46.0), size: CGSize(width: width - 100.0, height: separatorHeight)) + strongSelf.inputFirstField?.frame = CGRect(origin: CGPoint(x: 111.0, y: 12.0), size: CGSize(width: width - 111.0 - 8.0, height: 30.0)) + strongSelf.inputSecondField?.frame = CGRect(origin: CGPoint(x: 111.0, y: 52.0), size: CGSize(width: width - 111.0 - 8.0, height: 30.0)) + + if animated && animateIn { + strongSelf.inputSeparator?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + strongSelf.inputFirstField?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + strongSelf.inputSecondField?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } + case let .title(title): + if strongSelf.inputSeparator == nil { + let inputSeparator = ASDisplayNode() + inputSeparator.backgroundColor = UIColor(0xc8c7cc) + inputSeparator.isLayerBacked = true + strongSelf.addSubnode(inputSeparator) + strongSelf.inputSeparator = inputSeparator + } + + if strongSelf.inputFirstField == nil { + let inputFirstField = TextFieldNodeView() + inputFirstField.typingAttributes = [NSFontAttributeName: Font.regular(19.0)] + //inputFirstField.backgroundColor = UIColor.lightGray + inputFirstField.attributedPlaceholder = NSAttributedString(string: "Title", font: Font.regular(19.0), textColor: UIColor(0xc8c8ce)) + inputFirstField.attributedText = NSAttributedString(string: title, font: Font.regular(19.0), textColor: UIColor.black) + strongSelf.inputFirstField = inputFirstField + strongSelf.view.addSubview(inputFirstField) + inputFirstField.addTarget(self, action: #selector(strongSelf.textFieldDidChange(_:)), for: .editingChanged) + } else if strongSelf.inputFirstField?.text != title { + strongSelf.inputFirstField?.text = title + } + + strongSelf.inputSeparator?.frame = CGRect(origin: CGPoint(x: 100.0, y: 62.0), size: CGSize(width: width - 100.0, height: separatorHeight)) + strongSelf.inputFirstField?.frame = CGRect(origin: CGPoint(x: 102.0, y: 26.0), size: CGSize(width: width - 102.0 - 8.0, height: 35.0)) + + if animated && animateIn { + strongSelf.inputSeparator?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + strongSelf.inputFirstField?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } } - if animated { + if animated && animateIn { strongSelf.statusNode.layer.animateAlpha(from: CGFloat(strongSelf.statusNode.layer.opacity), to: 0.0, duration: 0.3) strongSelf.statusNode.alpha = 0.0 strongSelf.nameNode.layer.animateAlpha(from: CGFloat(strongSelf.nameNode.layer.opacity), to: 0.0, duration: 0.3) @@ -288,7 +402,9 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { strongSelf.nameNode.alpha = 0.0 } } else { + var animateOut = false if let inputSeparator = strongSelf.inputSeparator { + animateOut = true strongSelf.inputSeparator = nil if animated { @@ -303,23 +419,23 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { strongSelf.inputFirstField = nil if animated { inputFirstField.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak inputFirstField] _ in - inputFirstField?.removeFromSupernode() + inputFirstField?.removeFromSuperview() }) } else { - inputFirstField.removeFromSupernode() + inputFirstField.removeFromSuperview() } } if let inputSecondField = strongSelf.inputSecondField { strongSelf.inputSecondField = nil if animated { inputSecondField.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak inputSecondField] _ in - inputSecondField?.removeFromSupernode() + inputSecondField?.removeFromSuperview() }) } else { - inputSecondField.removeFromSupernode() + inputSecondField.removeFromSuperview() } } - if animated { + if animated && animateOut { strongSelf.statusNode.layer.animateAlpha(from: CGFloat(strongSelf.statusNode.layer.opacity), to: 1.0, duration: 0.3) strongSelf.statusNode.alpha = 1.0 strongSelf.nameNode.layer.animateAlpha(from: CGFloat(strongSelf.nameNode.layer.opacity), to: 1.0, duration: 0.3) @@ -333,4 +449,18 @@ class PeerInfoAvatarAndNameItemNode: ListViewItemNode { }) } } + + @objc func textFieldDidChange(_ inputField: UITextField) { + if let item = self.item { + var editingName: ItemListAvatarAndNameInfoItemName? + if let inputFirstField = self.inputFirstField, let inputSecondField = self.inputSecondField { + editingName = .personName(firstName: inputFirstField.text ?? "", lastName: inputSecondField.text ?? "") + } else if let inputFirstField = self.inputFirstField { + editingName = .title(title: inputFirstField.text ?? "") + } + if let editingName = editingName { + item.editingNameUpdated(editingName) + } + } + } } diff --git a/TelegramUI/ItemListCheckboxItem.swift b/TelegramUI/ItemListCheckboxItem.swift new file mode 100644 index 0000000000..facbb14694 --- /dev/null +++ b/TelegramUI/ItemListCheckboxItem.swift @@ -0,0 +1,235 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +/* + + + + Created with Sketch. + + + + */ + +private let checkIcon = generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor(0x007ee5).cgColor) + context.setLineWidth(2.0) + context.move(to: CGPoint(x: 12.0, y: 1.0)) + context.addLine(to: CGPoint(x: 4.16482734, y: 9.0)) + context.addLine(to: CGPoint(x: 1.0, y: 5.81145833)) + context.strokePath() +}) + +class ItemListCheckboxItem: ListViewItem, ItemListItem { + let title: String + let checked: Bool + let zeroSeparatorInsets: Bool + let sectionId: ItemListSectionId + let action: () -> Void + + init(title: String, checked: Bool, zeroSeparatorInsets: Bool, sectionId: ItemListSectionId, action: @escaping () -> Void) { + self.title = title + self.checked = checked + self.zeroSeparatorInsets = zeroSeparatorInsets + self.sectionId = sectionId + self.action = action + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListCheckboxItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply() }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? ItemListCheckboxItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + self.action() + } +} + +private let titleFont = Font.regular(17.0) + +class ItemListCheckboxItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + private let iconNode: ASImageNode + private let titleNode: TextNode + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.bottomStripeNode.isLayerBacked = true + + self.iconNode = ASImageNode() + self.iconNode.isLayerBacked = true + self.iconNode.displayWithoutProcessing = true + self.iconNode.displaysAsynchronously = false + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.iconNode) + self.addSubnode(self.titleNode) + } + + func asyncLayout() -> (_ item: ItemListCheckboxItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { item, width, neighbors in + let leftInset: CGFloat = 44.0 + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let separatorHeight = UIScreenPixel + + let insets = itemListNeighborsGroupedInsets(neighbors) + let contentSize = CGSize(width: width, height: 44.0) + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + return (layout, { [weak self] in + let image = checkIcon + + if let strongSelf = self { + let _ = titleApply() + + strongSelf.iconNode.image = image + if let image = image { + strongSelf.iconNode.frame = CGRect(origin: CGPoint(x: floor((leftInset - image.size.width) / 2.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size) + } + strongSelf.iconNode.isHidden = !item.checked + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = false + } + let bottomStripeInset: CGFloat + if item.zeroSeparatorInsets { + bottomStripeInset = 0.0 + } else { + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + default: + bottomStripeInset = 0.0 + } + } + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 44.0 + UIScreenPixel + UIScreenPixel)) + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + var anchorNode: ASDisplayNode? + if self.bottomStripeNode.supernode != nil { + anchorNode = self.bottomStripeNode + } else if self.topStripeNode.supernode != nil { + anchorNode = self.topStripeNode + } else if self.backgroundNode.supernode != nil { + anchorNode = self.backgroundNode + } + if let anchorNode = anchorNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) + } else { + self.addSubnode(self.highlightedBackgroundNode) + } + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/TelegramUI/ItemListController.swift b/TelegramUI/ItemListController.swift new file mode 100644 index 0000000000..c54f0e06c4 --- /dev/null +++ b/TelegramUI/ItemListController.swift @@ -0,0 +1,125 @@ +import Foundation +import Display +import SwiftSignalKit + +enum ItemListNavigationButtonStyle { + case regular + case bold + + var barButtonItemStyle: UIBarButtonItemStyle { + switch self { + case .regular: + return .plain + case .bold: + return .done + } + } +} + +struct ItemListNavigationButton { + let title: String + let style: ItemListNavigationButtonStyle + let enabled: Bool + let action: () -> Void +} + +struct ItemListControllerState { + let title: String + let leftNavigationButton: ItemListNavigationButton? + let rightNavigationButton: ItemListNavigationButton? +} + +final class ItemListController: ViewController { + private let state: Signal<(ItemListControllerState, (ItemListNodeState, Entry.ItemGenerationArguments)), NoError> + + private var leftNavigationButtonTitleAndStyle: (String, ItemListNavigationButtonStyle)? + private var rightNavigationButtonTitleAndStyle: (String, ItemListNavigationButtonStyle)? + private var navigationButtonActions: (left: (() -> Void)?, right: (() -> Void)?) = (nil, nil) + + init(_ state: Signal<(ItemListControllerState, (ItemListNodeState, Entry.ItemGenerationArguments)), NoError>) { + self.state = state + + super.init() + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadDisplayNode() { + let previousControllerState = Atomic(value: nil) + let nodeState = self.state |> deliverOnMainQueue |> afterNext { [weak self] controllerState, state in + Queue.mainQueue().async { + if let strongSelf = self { + let previousState = previousControllerState.swap(controllerState) + if previousState?.title != controllerState.title { + strongSelf.title = controllerState.title + } + strongSelf.navigationButtonActions = (left: controllerState.leftNavigationButton?.action, right: controllerState.rightNavigationButton?.action) + + if strongSelf.leftNavigationButtonTitleAndStyle?.0 != controllerState.leftNavigationButton?.title || strongSelf.leftNavigationButtonTitleAndStyle?.1 != controllerState.leftNavigationButton?.style { + if let leftNavigationButton = controllerState.leftNavigationButton { + let item = UIBarButtonItem(title: leftNavigationButton.title, style: leftNavigationButton.style.barButtonItemStyle, target: strongSelf, action: #selector(strongSelf.leftNavigationButtonPressed)) + strongSelf.leftNavigationButtonTitleAndStyle = (leftNavigationButton.title, leftNavigationButton.style) + strongSelf.navigationItem.setLeftBarButton(item, animated: false) + item.isEnabled = leftNavigationButton.enabled + } else { + strongSelf.leftNavigationButtonTitleAndStyle = nil + strongSelf.navigationItem.setLeftBarButton(nil, animated: false) + } + } else if let barButtonItem = strongSelf.navigationItem.leftBarButtonItem, let leftNavigationButton = controllerState.leftNavigationButton, leftNavigationButton.enabled != barButtonItem.isEnabled { + barButtonItem.isEnabled = leftNavigationButton.enabled + } + + if strongSelf.rightNavigationButtonTitleAndStyle?.0 != controllerState.rightNavigationButton?.title || strongSelf.rightNavigationButtonTitleAndStyle?.1 != controllerState.rightNavigationButton?.style { + if let rightNavigationButton = controllerState.rightNavigationButton { + let item = UIBarButtonItem(title: rightNavigationButton.title, style: rightNavigationButton.style.barButtonItemStyle, target: strongSelf, action: #selector(strongSelf.rightNavigationButtonPressed)) + strongSelf.rightNavigationButtonTitleAndStyle = (rightNavigationButton.title, rightNavigationButton.style) + strongSelf.navigationItem.setRightBarButton(item, animated: false) + item.isEnabled = rightNavigationButton.enabled + } else { + strongSelf.rightNavigationButtonTitleAndStyle = nil + strongSelf.navigationItem.setRightBarButton(nil, animated: false) + } + } else if let barButtonItem = strongSelf.navigationItem.rightBarButtonItem, let rightNavigationButton = controllerState.rightNavigationButton, rightNavigationButton.enabled != barButtonItem.isEnabled { + barButtonItem.isEnabled = rightNavigationButton.enabled + } + } + } + } |> map { $1 } + let displayNode = ItemListNode(state: nodeState) + displayNode.dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: true, completion: nil) + } + self.displayNode = displayNode + super.displayNodeDidLoad() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + (self.displayNode as! ItemListNode).containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } + + @objc func leftNavigationButtonPressed() { + self.navigationButtonActions.left?() + } + + @objc func rightNavigationButtonPressed() { + self.navigationButtonActions.right?() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments { + if case .modalSheet = presentationArguments.presentationAnimation { + (self.displayNode as! ItemListNode).animateIn() + } + } + } + + func dismiss() { + (self.displayNode as! ItemListNode).animateOut() + } +} diff --git a/TelegramUI/ItemListControllerNode.swift b/TelegramUI/ItemListControllerNode.swift new file mode 100644 index 0000000000..16e306f544 --- /dev/null +++ b/TelegramUI/ItemListControllerNode.swift @@ -0,0 +1,181 @@ +import Foundation +import Display +import SwiftSignalKit +import TelegramCore + +typealias ItemListSectionId = Int32 + +protocol ItemListNodeEntry: Equatable, Comparable, Identifiable { + associatedtype ItemGenerationArguments + + var section: ItemListSectionId { get } + + func item(_ arguments: ItemGenerationArguments) -> ListViewItem +} + +private struct ItemListNodeEntryTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func preparedItemListNodeEntryTransition(from fromEntries: [Entry], to toEntries: [Entry], arguments: Entry.ItemGenerationArguments) -> ItemListNodeEntryTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(arguments), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(arguments), directionHint: nil) } + + return ItemListNodeEntryTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +enum ItemListStyle { + case plain + case blocks +} + +private struct ItemListNodeTransition { + let entries: ItemListNodeEntryTransition + let updateStyle: ItemListStyle? + let firstTime: Bool + let animated: Bool +} + +struct ItemListNodeState { + let entries: [Entry] + let style: ItemListStyle +} + +final class ItemListNode: ASDisplayNode { + private var _ready = ValuePromise() + public var ready: Signal { + return self._ready.get() + } + private var didSetReady = false + + private let listNode: ListView + private let transitionDisposable = MetaDisposable() + + private var enqueuedTransitions: [ItemListNodeTransition] = [] + private var hadValidLayout = false + + var dismiss: (() -> Void)? + + init(state: Signal<(ItemListNodeState, Entry.ItemGenerationArguments), NoError>) { + self.listNode = ListView() + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.addSubnode(self.listNode) + + self.backgroundColor = UIColor(0xefeff4) + + let previousState = Atomic?>(value: nil) + self.transitionDisposable.set(((state |> map { state, arguments -> ItemListNodeTransition in + assert(state.entries == state.entries.sorted()) + let previous = previousState.swap(state) + let transition = preparedItemListNodeEntryTransition(from: previous?.entries ?? [], to: state.entries, arguments: arguments) + var updatedStyle: ItemListStyle? + if previous?.style != state.style { + updatedStyle = state.style + } + return ItemListNodeTransition(entries: transition, updateStyle: updatedStyle, firstTime: previous == nil, animated: previous != nil) + }) |> deliverOnMainQueue).start(next: { [weak self] transition in + if let strongSelf = self { + strongSelf.enqueueTransition(transition) + } + })) + } + + deinit { + self.transitionDisposable.dispose() + } + + func animateIn() { + self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) + } + + func animateOut() { + self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: kCAMediaTimingFunctionEaseInEaseOut, removeOnCompletion: false, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.dismiss?() + } + }) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + var duration: Double = 0.0 + var curve: UInt = 0 + switch transition { + case .immediate: + break + case let .animated(animationDuration, animationCurve): + duration = animationDuration + switch animationCurve { + case .easeInOut: + break + case .spring: + curve = 7 + } + } + + let listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + var insets = layout.insets(options: [.input]) + insets.top += navigationBarHeight + + self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + self.listNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: listViewCurve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !self.hadValidLayout { + self.hadValidLayout = true + self.dequeueTransitions() + } + } + + private func enqueueTransition(_ transition: ItemListNodeTransition) { + self.enqueuedTransitions.append(transition) + if self.hadValidLayout { + self.dequeueTransitions() + } + } + + private func dequeueTransitions() { + while !self.enqueuedTransitions.isEmpty { + let transition = self.enqueuedTransitions.removeFirst() + + if let updateStyle = transition.updateStyle { + switch updateStyle { + case .plain: + self.backgroundColor = .white + case .blocks: + self.backgroundColor = UIColor(0xefeff4) + } + } + var options = ListViewDeleteAndInsertOptions() + if transition.firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else if transition.animated { + options.insert(.AnimateInsertion) + } + self.listNode.transaction(deleteIndices: transition.entries.deletions, insertIndicesAndItems: transition.entries.insertions, updateIndicesAndItems: transition.entries.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self { + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + strongSelf._ready.set(true) + } + } + }) + } + } +} diff --git a/TelegramUI/PeerInfoDisclosureItem.swift b/TelegramUI/ItemListDisclosureItem.swift similarity index 86% rename from TelegramUI/PeerInfoDisclosureItem.swift rename to TelegramUI/ItemListDisclosureItem.swift index e7ee0d5633..f5759154d3 100644 --- a/TelegramUI/PeerInfoDisclosureItem.swift +++ b/TelegramUI/ItemListDisclosureItem.swift @@ -3,14 +3,14 @@ import Display import AsyncDisplayKit import SwiftSignalKit -class PeerInfoDisclosureItem: ListViewItem, PeerInfoItem { +class ItemListDisclosureItem: ListViewItem, ItemListItem { let title: String let label: String - let sectionId: PeerInfoItemSectionId - let style: PeerInfoListStyle + let sectionId: ItemListSectionId + let style: ItemListStyle let action: () -> Void - init(title: String, label: String, sectionId: PeerInfoItemSectionId, style: PeerInfoListStyle, action: @escaping () -> Void) { + init(title: String, label: String, sectionId: ItemListSectionId, style: ItemListStyle, action: @escaping () -> Void) { self.title = title self.label = label self.sectionId = sectionId @@ -20,8 +20,8 @@ class PeerInfoDisclosureItem: ListViewItem, PeerInfoItem { func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { - let node = PeerInfoDisclosureItemNode() - let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let node = ItemListDisclosureItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) node.contentSize = layout.contentSize node.insets = layout.insets @@ -33,12 +33,12 @@ class PeerInfoDisclosureItem: ListViewItem, PeerInfoItem { } func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { - if let node = node as? PeerInfoDisclosureItemNode { + if let node = node as? ItemListDisclosureItemNode { Queue.mainQueue().async { let makeLayout = node.asyncLayout() async { - let (layout, apply) = makeLayout(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { completion(layout, { apply() @@ -60,7 +60,7 @@ class PeerInfoDisclosureItem: ListViewItem, PeerInfoItem { private let titleFont = Font.regular(17.0) private let arrowImage = UIImage(bundleImageName: "Peer Info/DisclosureArrow")?.precomposed() -class PeerInfoDisclosureItemNode: ListViewItemNode { +class ItemListDisclosureItemNode: ListViewItemNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode @@ -106,7 +106,7 @@ class PeerInfoDisclosureItemNode: ListViewItemNode { self.addSubnode(self.arrowNode) } - func asyncLayout() -> (_ item: PeerInfoDisclosureItem, _ width: CGFloat, _ insets: PeerInfoItemNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ItemListDisclosureItem, _ width: CGFloat, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeLabelLayout = TextNode.asyncLayout(self.labelNode) @@ -121,17 +121,10 @@ class PeerInfoDisclosureItemNode: ListViewItemNode { switch item.style { case .plain: contentSize = CGSize(width: width, height: 44.0) - insets = peerInfoItemNeighborsPlainInsets(neighbors) + insets = itemListNeighborsPlainInsets(neighbors) case .blocks: contentSize = CGSize(width: width, height: 44.0) - let topInset: CGFloat - switch neighbors.top { - case .sameSection, .none: - topInset = 0.0 - case .otherSection: - topInset = separatorHeight + 35.0 - } - insets = UIEdgeInsets(top: topInset, left: 0.0, bottom: separatorHeight, right: 0.0) + insets = itemListNeighborsGroupedInsets(neighbors) } let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) @@ -175,21 +168,22 @@ class PeerInfoDisclosureItemNode: ListViewItemNode { strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) } switch neighbors.top { - case .sameSection: + case .sameSection(false): strongSelf.topStripeNode.isHidden = true - case .none, .otherSection: + default: strongSelf.topStripeNode.isHidden = false } let bottomStripeInset: CGFloat switch neighbors.bottom { - case .sameSection: + case .sameSection(false): bottomStripeInset = 16.0 - case .none, .otherSection: + default: bottomStripeInset = 0.0 } + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: layoutSize.height - insets.top - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) } strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) diff --git a/TelegramUI/ItemListEditableDeleteControlNode.swift b/TelegramUI/ItemListEditableDeleteControlNode.swift new file mode 100644 index 0000000000..b57c67ff37 --- /dev/null +++ b/TelegramUI/ItemListEditableDeleteControlNode.swift @@ -0,0 +1,60 @@ +import Foundation +import AsyncDisplayKit +import Display + +private let deleteIndicator = generateImage(CGSize(width: 22.0, height: 26.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(white: 0.0, alpha: 0.06).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 22.0, height: 22.0))) + context.setFillColor(UIColor(0xfc2125).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 2.0), size: CGSize(width: 22.0, height: 22.0))) + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - 11.0) / 2.0), y: 2.0 + floorToScreenPixels((size.width - 1.0) / 2.0)), size: CGSize(width: 11.0, height: 1.0))) +}) + +final class ItemListEditableControlNode: ASDisplayNode { + var tapped: (() -> Void)? + private let iconNode: ASImageNode + + override init() { + self.iconNode = ASImageNode() + self.iconNode.isLayerBacked = true + + super.init() + + self.addSubnode(self.iconNode) + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + static func asyncLayout(_ node: ItemListEditableControlNode?) -> (_ height: CGFloat) -> (CGSize, () -> ItemListEditableControlNode) { + return { height in + let image = deleteIndicator + + let resultNode: ItemListEditableControlNode + if let node = node { + resultNode = node + } else { + resultNode = ItemListEditableControlNode() + resultNode.iconNode.image = image + } + + return (CGSize(width: 38.0, height: height), { + if let image = image { + resultNode.iconNode.frame = CGRect(origin: CGPoint(x: 12.0, y: floor((height - image.size.height) / 2.0)), size: image.size) + } + return resultNode + }) + } + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.tapped?() + } + } +} diff --git a/TelegramUI/ItemListEditableItem.swift b/TelegramUI/ItemListEditableItem.swift new file mode 100644 index 0000000000..c8ef0be863 --- /dev/null +++ b/TelegramUI/ItemListEditableItem.swift @@ -0,0 +1,252 @@ +import Foundation +import Display +import AsyncDisplayKit + +final class ItemListRevealOptionsGestureRecognizer: UIPanGestureRecognizer { + var validatedGesture = false + var firstLocation: CGPoint = CGPoint() + + var allowAnyDirection = false + var lastVelocity: CGPoint = CGPoint() + + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + + self.maximumNumberOfTouches = 1 + } + + override func reset() { + super.reset() + + validatedGesture = false + } + + func becomeCancelled() { + self.state = .cancelled + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + let touch = touches.first! + self.firstLocation = touch.location(in: self.view) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + let location = touches.first!.location(in: self.view) + let translation = CGPoint(x: location.x - firstLocation.x, y: location.y - firstLocation.y) + + if !validatedGesture { + if !self.allowAnyDirection && translation.x > 0.0 { + self.state = .failed + } else if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 { + self.state = .failed + } else if abs(translation.x) > 4.0 && abs(translation.y) * 2.5 < abs(translation.x) { + validatedGesture = true + } + } + + if validatedGesture { + self.lastVelocity = self.velocity(in: self.view) + super.touchesMoved(touches, with: event) + } + } +} + +class ItemListRevealOptionsItemNode: ListViewItemNode { + private var revealNode: ItemListRevealOptionsNode? + private var revealOptions: [ItemListRevealOption] = [] + + private var initialRevealOffset: CGFloat = 0.0 + private(set) var revealOffset: CGFloat = 0.0 + + private var recognizer: ItemListRevealOptionsGestureRecognizer? + + private var allowAnyDirection = false + + var isDisplayingRevealedOptions: Bool { + return !self.revealOffset.isZero + } + + override var canBeSelected: Bool { + return !self.isDisplayingRevealedOptions + } + + override init(layerBacked: Bool, dynamicBounce: Bool, rotated: Bool) { + super.init(layerBacked: layerBacked, dynamicBounce: dynamicBounce, rotated: rotated) + } + + override func didLoad() { + super.didLoad() + + let recognizer = ItemListRevealOptionsGestureRecognizer(target: self, action: #selector(self.revealGesture(_:))) + self.recognizer = recognizer + recognizer.allowAnyDirection = self.allowAnyDirection + self.view.addGestureRecognizer(recognizer) + } + + func setRevealOptions(_ options: [ItemListRevealOption]) { + let wasEmpty = self.revealOptions.isEmpty + self.revealOptions = options + if options.isEmpty { + if let _ = self.revealNode { + self.recognizer?.becomeCancelled() + self.updateRevealOffsetInternal(offset: 0.0, transition: .animated(duration: 0.3, curve: .spring)) + } + } else { + if let revealNode = self.revealNode { + revealNode.setOptions(options) + } + } + if wasEmpty != options.isEmpty { + self.recognizer?.isEnabled = !options.isEmpty + } + } + + @objc func revealGesture(_ recognizer: ItemListRevealOptionsGestureRecognizer) { + switch recognizer.state { + case .began: + if let revealNode = self.revealNode { + let revealSize = revealNode.calculatedSize + let location = recognizer.location(in: self.view) + if location.x > self.bounds.size.width - revealSize.width { + recognizer.becomeCancelled() + } else { + self.initialRevealOffset = self.revealOffset + } + } else { + if self.revealOptions.isEmpty { + recognizer.becomeCancelled() + } + self.initialRevealOffset = self.revealOffset + } + case .changed: + var translation = recognizer.translation(in: self.view) + translation.x += self.initialRevealOffset + if self.revealNode == nil && translation.x.isLess(than: 0.0) { + self.setupAndAddRevealNode() + self.revealOptionsInteractivelyOpened() + } + self.updateRevealOffsetInternal(offset: translation.x, transition: .immediate) + case .ended, .cancelled: + if let recognizer = self.recognizer, let revealNode = self.revealNode { + let velocity = recognizer.velocity(in: self.view) + let revealSize = revealNode.calculatedSize + var reveal = false + if abs(velocity.x) < 100.0 { + if self.initialRevealOffset.isZero && self.revealOffset < 0.0 { + reveal = true + } else if self.revealOffset < -revealSize.width { + reveal = true + } else { + reveal = false + } + } else { + if velocity.x < 0.0 { + reveal = true + } else { + reveal = false + } + } + self.updateRevealOffsetInternal(offset: reveal ? -revealSize.width : 0.0, transition: .animated(duration: 0.3, curve: .spring)) + if !reveal { + self.revealOptionsInteractivelyClosed() + } + } + default: + break + } + } + + private func setupAndAddRevealNode() { + if !self.revealOptions.isEmpty { + let revealNode = ItemListRevealOptionsNode(optionSelected: { [weak self] option in + self?.revealOptionSelected(option) + }) + revealNode.setOptions(self.revealOptions) + self.revealNode = revealNode + + let revealSize = revealNode.measure(CGSize(width: CGFloat.greatestFiniteMagnitude, height: self.bounds.size.height)) + revealNode.frame = CGRect(origin: CGPoint(x: self.bounds.size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize) + + self.addSubnode(revealNode) + } + } + + override func layout() { + if let revealNode = self.revealNode { + let height = self.bounds.size.height + let revealSize = revealNode.measure(CGSize(width: CGFloat.greatestFiniteMagnitude, height: height)) + revealNode.frame = CGRect(origin: CGPoint(x: self.bounds.size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize) + } + } + + func updateRevealOffsetInternal(offset: CGFloat, transition: ContainedViewLayoutTransition) { + self.revealOffset = offset + if let revealNode = self.revealNode { + let revealSize = revealNode.calculatedSize + + let revealFrame = CGRect(origin: CGPoint(x: self.bounds.size.width + max(self.revealOffset, -revealSize.width), y: 0.0), size: revealSize) + let revealNodeOffset = -max(self.revealOffset, -revealSize.width) + revealNode.updateRevealOffset(offset: revealNodeOffset, transition: transition) + + if CGFloat(0.0).isLessThanOrEqualTo(offset) { + self.revealNode = nil + transition.updateFrame(node: revealNode, frame: revealFrame, completion: { [weak revealNode] _ in + revealNode?.removeFromSupernode() + }) + } else { + transition.updateFrame(node: revealNode, frame: revealFrame) + } + } + let allowAnyDirection = !offset.isZero + if allowAnyDirection != self.allowAnyDirection { + self.allowAnyDirection = allowAnyDirection + self.recognizer?.allowAnyDirection = allowAnyDirection + self.view.disablesInteractiveTransitionGestureRecognizer = allowAnyDirection + } + + self.updateRevealOffset(offset: offset, transition: transition) + } + + func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + + } + + func revealOptionsInteractivelyOpened() { + + } + + func revealOptionsInteractivelyClosed() { + + } + + func setRevealOptionsOpened(_ value: Bool, animated: Bool) { + if value != !self.revealOffset.isZero { + if !self.revealOffset.isZero { + self.recognizer?.becomeCancelled() + } + let transition: ContainedViewLayoutTransition + if animated { + transition = .animated(duration: 0.3, curve: .spring) + } else { + transition = .immediate + } + if value { + if self.revealNode == nil { + self.setupAndAddRevealNode() + if let revealNode = self.revealNode { + revealNode.layout() + let revealSize = revealNode.calculatedSize + self.updateRevealOffsetInternal(offset: -revealSize.width, transition: transition) + } + } + } else if !self.revealOffset.isZero { + self.updateRevealOffsetInternal(offset: 0.0, transition: transition) + } + } + } + + func revealOptionSelected(_ option: ItemListRevealOption) { + } +} diff --git a/TelegramUI/ItemListItem.swift b/TelegramUI/ItemListItem.swift new file mode 100644 index 0000000000..4b381bb4ae --- /dev/null +++ b/TelegramUI/ItemListItem.swift @@ -0,0 +1,86 @@ +import Display + +protocol ItemListItem { + var sectionId: ItemListSectionId { get } + var isAlwaysPlain: Bool { get } +} + +extension ItemListItem { + var isAlwaysPlain: Bool { + return false + } +} + +enum ItemListNeighbor { + case none + case otherSection + case sameSection(alwaysPlain: Bool) +} + +struct ItemListNeighbors { + let top: ItemListNeighbor + let bottom: ItemListNeighbor +} + +func itemListNeighbors(item: ItemListItem, topItem: ItemListItem?, bottomItem: ItemListItem?) -> ItemListNeighbors { + let topNeighbor: ItemListNeighbor + if let topItem = topItem { + if topItem.sectionId != item.sectionId { + topNeighbor = .otherSection + } else { + topNeighbor = .sameSection(alwaysPlain: topItem.isAlwaysPlain) + } + } else { + topNeighbor = .none + } + + let bottomNeighbor: ItemListNeighbor + if let bottomItem = bottomItem { + if bottomItem.sectionId != item.sectionId { + bottomNeighbor = .otherSection + } else { + bottomNeighbor = .sameSection(alwaysPlain: bottomItem.isAlwaysPlain) + } + } else { + bottomNeighbor = .none + } + + return ItemListNeighbors(top: topNeighbor, bottom: bottomNeighbor) +} + +func itemListNeighborsPlainInsets(_ neighbors: ItemListNeighbors) -> UIEdgeInsets { + var insets = UIEdgeInsets() + switch neighbors.top { + case .otherSection: + insets.top += 22.0 + case .none, .sameSection: + break + } + switch neighbors.bottom { + case .none: + insets.bottom += 22.0 + case .otherSection, .sameSection: + break + } + return insets +} + +func itemListNeighborsGroupedInsets(_ neighbors: ItemListNeighbors) -> UIEdgeInsets { + let topInset: CGFloat + switch neighbors.top { + case .none: + topInset = UIScreenPixel + 35.0 + case .sameSection: + topInset = 0.0 + case .otherSection: + topInset = UIScreenPixel + 35.0 + } + let bottomInset: CGFloat + switch neighbors.bottom { + case .sameSection, .otherSection: + bottomInset = 0.0 + case .none: + bottomInset = UIScreenPixel + 35.0 + } + return UIEdgeInsets(top: topInset, left: 0.0, bottom: bottomInset, right: 0.0) +} diff --git a/TelegramUI/ItemListMultilineInputItem.swift b/TelegramUI/ItemListMultilineInputItem.swift new file mode 100644 index 0000000000..bb41b71685 --- /dev/null +++ b/TelegramUI/ItemListMultilineInputItem.swift @@ -0,0 +1,203 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +class ItemListMultilineInputItem: ListViewItem, ItemListItem { + let text: String + let sectionId: ItemListSectionId + let action: () -> Void + let textUpdated: (String) -> Void + + init(text: String, sectionId: ItemListSectionId, textUpdated: @escaping (String) -> Void, action: @escaping () -> Void) { + self.text = text + self.sectionId = sectionId + self.textUpdated = textUpdated + self.action = action + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListMultilineInputItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply() }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? ItemListMultilineInputItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } +} + +private let titleFont = Font.regular(17.0) + +class ItemListMultilineInputItemNode: ListViewItemNode, ASEditableTextNodeDelegate { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + + private let textClippingNode: ASDisplayNode + private let textNode: ASEditableTextNode + private let measureTextNode: TextNode + + private var item: ItemListMultilineInputItem? + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.bottomStripeNode.isLayerBacked = true + + self.textClippingNode = ASDisplayNode() + self.textClippingNode.clipsToBounds = true + + self.textNode = ASEditableTextNode() + self.measureTextNode = TextNode() + + super.init(layerBacked: false, dynamicBounce: false) + + self.textClippingNode.addSubnode(self.textNode) + self.addSubnode(self.textClippingNode) + } + + override func didLoad() { + super.didLoad() + + self.textNode.typingAttributes = [NSFontAttributeName: Font.regular(17.0)] + self.textNode.clipsToBounds = true + self.textNode.delegate = self + self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0) + } + + func asyncLayout() -> (_ item: ItemListMultilineInputItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + let makeTextLayout = TextNode.asyncLayout(self.measureTextNode) + + return { item, width, neighbors in + let leftInset: CGFloat = 16.0 + + var measureText = item.text + if measureText.hasSuffix("\n") || measureText.isEmpty { + measureText += "|" + } + let attributedMeasureText = NSAttributedString(string: measureText, font: Font.regular(17.0), textColor: .black) + let attributedText = NSAttributedString(string: item.text, font: Font.regular(17.0), textColor: .black) + let (textLayout, textApply) = makeTextLayout(attributedMeasureText, nil, 0, .end, CGSize(width: width - 8 - leftInset, height: CGFloat.greatestFiniteMagnitude), nil) + + let separatorHeight = UIScreenPixel + + let textTopInset: CGFloat = 11.0 + let textBottomInset: CGFloat = 11.0 + + let contentSize = CGSize(width: width, height: textLayout.size.height + textTopInset + textBottomInset) + let insets = itemListNeighborsGroupedInsets(neighbors) + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + return (layout, { [weak self] in + if let strongSelf = self { + strongSelf.item = item + + let _ = textApply() + if let currentText = strongSelf.textNode.attributedText { + if !currentText.isEqual(to: attributedText) { + strongSelf.textNode.attributedText = attributedText + } + } else { + strongSelf.textNode.attributedText = attributedText + } + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = false + } + let bottomStripeInset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + default: + bottomStripeInset = 0.0 + } + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + + strongSelf.textClippingNode.frame = CGRect(origin: CGPoint(x: leftInset, y: textTopInset), size: CGSize(width: width - leftInset, height: textLayout.size.height)) + strongSelf.textNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: width - leftInset - 8.0, height: textLayout.size.height)) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { + super.animateFrameTransition(progress, currentValue) + + let separatorHeight = UIScreenPixel + let insets = self.insets + let width = self.bounds.size.width + let contentSize = CGSize(width: width, height: currentValue - insets.top - insets.bottom) + let leftInset: CGFloat = 16.0 + let textTopInset: CGFloat = 11.0 + let textBottomInset: CGFloat = 11.0 + + self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + self.bottomStripeNode.frame = CGRect(origin: CGPoint(x: self.bottomStripeNode.frame.minX, y: contentSize.height), size: CGSize(width: self.bottomStripeNode.frame.size.width, height: separatorHeight)) + + self.textClippingNode.frame = CGRect(origin: CGPoint(x: leftInset, y: textTopInset), size: CGSize(width: max(0.0, width - leftInset), height: max(0.0, contentSize.height - textTopInset - textBottomInset))) + } + + func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { + if let item = self.item { + if let text = self.textNode.attributedText?.string { + item.textUpdated(text) + } else { + item.textUpdated("") + } + } + } +} diff --git a/TelegramUI/PeerInfoPeerActionItem.swift b/TelegramUI/ItemListPeerActionItem.swift similarity index 84% rename from TelegramUI/PeerInfoPeerActionItem.swift rename to TelegramUI/ItemListPeerActionItem.swift index 48af660289..8287565cd9 100644 --- a/TelegramUI/PeerInfoPeerActionItem.swift +++ b/TelegramUI/ItemListPeerActionItem.swift @@ -3,13 +3,13 @@ import Display import AsyncDisplayKit import SwiftSignalKit -class PeerInfoPeerActionItem: ListViewItem, PeerInfoItem { +class ItemListPeerActionItem: ListViewItem, ItemListItem { let icon: UIImage? let title: String - let sectionId: PeerInfoItemSectionId + let sectionId: ItemListSectionId let action: () -> Void - init(icon: UIImage?, title: String, sectionId: PeerInfoItemSectionId, action: @escaping () -> Void) { + init(icon: UIImage?, title: String, sectionId: ItemListSectionId, action: @escaping () -> Void) { self.icon = icon self.title = title self.sectionId = sectionId @@ -18,8 +18,8 @@ class PeerInfoPeerActionItem: ListViewItem, PeerInfoItem { func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { - let node = PeerInfoPeerActionItemNode() - let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let node = ItemListPeerActionItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) node.contentSize = layout.contentSize node.insets = layout.insets @@ -31,12 +31,12 @@ class PeerInfoPeerActionItem: ListViewItem, PeerInfoItem { } func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { - if let node = node as? PeerInfoPeerActionItemNode { + if let node = node as? ItemListPeerActionItemNode { Queue.mainQueue().async { let makeLayout = node.asyncLayout() async { - let (layout, apply) = makeLayout(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { completion(layout, { apply() @@ -57,7 +57,7 @@ class PeerInfoPeerActionItem: ListViewItem, PeerInfoItem { private let titleFont = Font.regular(17.0) -class PeerInfoPeerActionItemNode: ListViewItemNode { +class ItemListPeerActionItemNode: ListViewItemNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode @@ -99,7 +99,7 @@ class PeerInfoPeerActionItemNode: ListViewItemNode { self.addSubnode(self.titleNode) } - func asyncLayout() -> (_ item: PeerInfoPeerActionItem, _ width: CGFloat, _ neighbors: PeerInfoItemNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ItemListPeerActionItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) return { item, width, neighbors in @@ -107,19 +107,10 @@ class PeerInfoPeerActionItemNode: ListViewItemNode { let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: UIColor(0x007ee5)), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) - let contentSize: CGSize - let insets: UIEdgeInsets let separatorHeight = UIScreenPixel - contentSize = CGSize(width: width, height: 44.0) - let topInset: CGFloat - switch neighbors.top { - case .sameSection, .none: - topInset = 0.0 - case .otherSection: - topInset = separatorHeight + 35.0 - } - insets = UIEdgeInsets(top: topInset, left: 0.0, bottom: separatorHeight, right: 0.0) + let insets = itemListNeighborsGroupedInsets(neighbors) + let contentSize = CGSize(width: width, height: 44.0) let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size @@ -143,21 +134,24 @@ class PeerInfoPeerActionItemNode: ListViewItemNode { strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) } switch neighbors.top { - case .sameSection: + case .sameSection(false): strongSelf.topStripeNode.isHidden = true - case .none, .otherSection: + default: strongSelf.topStripeNode.isHidden = false } let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat switch neighbors.bottom { - case .sameSection: + case .sameSection(false): bottomStripeInset = leftInset - case .none, .otherSection: + bottomStripeOffset = -separatorHeight + default: bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 } strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) diff --git a/TelegramUI/PeerInfoPeerItem.swift b/TelegramUI/ItemListPeerItem.swift similarity index 84% rename from TelegramUI/PeerInfoPeerItem.swift rename to TelegramUI/ItemListPeerItem.swift index fb2ff25cb7..74018d0fec 100644 --- a/TelegramUI/PeerInfoPeerItem.swift +++ b/TelegramUI/ItemListPeerItem.swift @@ -5,15 +5,15 @@ import SwiftSignalKit import Postbox import TelegramCore -final class PeerInfoPeerItem: ListViewItem, PeerInfoItem { +final class ItemListPeerItem: ListViewItem, ItemListItem { let account: Account let peer: Peer? let presence: PeerPresence? let label: String? - let sectionId: PeerInfoItemSectionId - let action: () -> Void + let sectionId: ItemListSectionId + let action: (() -> Void)? - init(account: Account, peer: Peer?, presence: PeerPresence?, label: String?, sectionId: PeerInfoItemSectionId, action: @escaping () -> Void) { + init(account: Account, peer: Peer?, presence: PeerPresence?, label: String?, sectionId: ItemListSectionId, action: (() -> Void)?) { self.account = account self.peer = peer self.presence = presence @@ -24,8 +24,8 @@ final class PeerInfoPeerItem: ListViewItem, PeerInfoItem { func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { - let node = PeerInfoPeerItemNode() - let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let node = ItemListPeerItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) node.contentSize = layout.contentSize node.insets = layout.insets @@ -37,12 +37,12 @@ final class PeerInfoPeerItem: ListViewItem, PeerInfoItem { } func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { - if let node = node as? PeerInfoPeerItemNode { + if let node = node as? ItemListPeerItemNode { Queue.mainQueue().async { let makeLayout = node.asyncLayout() async { - let (layout, apply) = makeLayout(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { completion(layout, { apply() @@ -57,7 +57,7 @@ final class PeerInfoPeerItem: ListViewItem, PeerInfoItem { func selected(listView: ListView){ listView.clearHighlightAnimated(true) - self.action() + self.action?() } } @@ -67,7 +67,7 @@ private let statusFont = Font.regular(14.0) private let labelFont = Font.regular(13.0) private let avatarFont = Font.regular(17.0) -class PeerInfoPeerItemNode: ListViewItemNode { +class ItemListPeerItemNode: ItemListRevealOptionsItemNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode @@ -79,7 +79,15 @@ class PeerInfoPeerItemNode: ListViewItemNode { private let statusNode: TextNode private var peerPresenceManager: PeerPresenceStatusManager? - private var layoutParams: (PeerInfoPeerItem, CGFloat, PeerInfoItemNeighbors)? + private var layoutParams: (ItemListPeerItem, CGFloat, ItemListNeighbors)? + + override var canBeSelected: Bool { + if let item = self.layoutParams?.0, item.action != nil { + return true + } else { + return false + } + } init() { self.backgroundNode = ASDisplayNode() @@ -116,7 +124,7 @@ class PeerInfoPeerItemNode: ListViewItemNode { self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) self.highlightedBackgroundNode.isLayerBacked = true - super.init(layerBacked: false, dynamicBounce: false) + super.init(layerBacked: false, dynamicBounce: false, rotated: false) self.addSubnode(self.avatarNode) self.addSubnode(self.titleNode) @@ -131,7 +139,7 @@ class PeerInfoPeerItemNode: ListViewItemNode { }) } - func asyncLayout() -> (_ item: PeerInfoPeerItem, _ width: CGFloat, _ neighbors: PeerInfoItemNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ItemListPeerItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeStatusLayout = TextNode.asyncLayout(self.statusNode) let makeLabelLayout = TextNode.asyncLayout(self.labelNode) @@ -182,27 +190,10 @@ class PeerInfoPeerItemNode: ListViewItemNode { let (titleLayout, titleApply) = makeTitleLayout(titleAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - labelLayout.size.width, height: CGFloat.greatestFiniteMagnitude), nil) 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), height: CGFloat.greatestFiniteMagnitude), nil) - let contentSize: CGSize - let insets: UIEdgeInsets + let insets = itemListNeighborsGroupedInsets(neighbors) + let contentSize = CGSize(width: width, height: 48.0) let separatorHeight = UIScreenPixel - contentSize = CGSize(width: width, height: 48.0) - let topInset: CGFloat - let bottomInset: CGFloat - switch neighbors.top { - case .sameSection, .none: - topInset = 0.0 - case .otherSection: - topInset = separatorHeight + 35.0 - } - switch neighbors.bottom { - case .none: - bottomInset = separatorHeight + 35.0 - case .otherSection, .sameSection: - bottomInset = separatorHeight - } - insets = UIEdgeInsets(top: topInset, left: 0.0, bottom: bottomInset, right: 0.0) - let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size @@ -210,6 +201,8 @@ class PeerInfoPeerItemNode: ListViewItemNode { if let strongSelf = self { strongSelf.layoutParams = (item, width, neighbors) + let revealOffset = strongSelf.revealOffset + let _ = titleApply() let _ = statusApply() let _ = labelApply() @@ -226,27 +219,30 @@ class PeerInfoPeerItemNode: ListViewItemNode { strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) } switch neighbors.top { - case .sameSection: + case .sameSection(false): strongSelf.topStripeNode.isHidden = true - case .none, .otherSection: + default: strongSelf.topStripeNode.isHidden = false } let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat switch neighbors.bottom { - case .sameSection: + case .sameSection(false): bottomStripeInset = leftInset - case .none, .otherSection: + bottomStripeOffset = -separatorHeight + default: bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 } strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) - strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 5.0), size: titleLayout.size) - strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 25.0), size: statusLayout.size) - strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: width - labelLayout.size.width - 15.0, y: floor((contentSize.height - labelLayout.size.height) / 2.0 - labelLayout.size.height / 10.0)), size: labelLayout.size) + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset + revealOffset, y: 5.0), size: titleLayout.size) + strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: leftInset + revealOffset, y: 25.0), size: statusLayout.size) + strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: revealOffset + width - labelLayout.size.width - 15.0, y: floor((contentSize.height - labelLayout.size.height) / 2.0 - labelLayout.size.height / 10.0)), size: labelLayout.size) - strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 4.0), size: CGSize(width: 40.0, height: 40.0)) + strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: revealOffset + 12.0, y: 4.0), size: CGSize(width: 40.0, height: 40.0)) if let peer = item.peer { strongSelf.avatarNode.setPeer(account: item.account, peer: peer) } @@ -306,4 +302,10 @@ class PeerInfoPeerItemNode: ListViewItemNode { override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } + + override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + super.updateRevealOffset(offset: offset, transition: transition) + + + } } diff --git a/TelegramUI/ItemListRevealOptionsNode.swift b/TelegramUI/ItemListRevealOptionsNode.swift new file mode 100644 index 0000000000..8b6d116ae6 --- /dev/null +++ b/TelegramUI/ItemListRevealOptionsNode.swift @@ -0,0 +1,164 @@ +import Foundation +import AsyncDisplayKit +import Display + +struct ItemListRevealOption: Equatable { + let key: Int32 + let title: String + let icon: UIImage? + let color: UIColor + + static func ==(lhs: ItemListRevealOption, rhs: ItemListRevealOption) -> Bool { + if lhs.key != rhs.key { + return false + } + if lhs.title != rhs.title { + return false + } + if !lhs.color.isEqual(rhs.color) { + return false + } + if lhs.icon !== rhs.icon { + return false + } + return true + } +} + +private let titleFontWithIcon = Font.regular(13.0) +private let titleFontWithoutIcon = Font.regular(17.0) + +final class ItemListRevealOptionNode: ASDisplayNode { + private let titleNode: ASTextNode + private let iconNode: ASImageNode? + + init(title: String, icon: UIImage?, color: UIColor) { + self.titleNode = ASTextNode() + self.titleNode.attributedText = NSAttributedString(string: title, font: icon == nil ? titleFontWithoutIcon : titleFontWithIcon, textColor: .white) + + if let icon = icon { + let iconNode = ASImageNode() + iconNode.image = icon + self.iconNode = iconNode + } else { + self.iconNode = nil + } + + super.init() + + self.addSubnode(self.titleNode) + if let iconNode = self.iconNode { + self.addSubnode(iconNode) + } + self.backgroundColor = color + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + let titleSize = self.titleNode.measure(constrainedSize) + var maxWidth = titleSize.width + if let iconNode = self.iconNode, let image = iconNode.image { + maxWidth = max(image.size.width, maxWidth) + } + return CGSize(width: max(74.0, maxWidth + 20.0), height: constrainedSize.height) + } + + override func layout() { + super.layout() + + let size = self.bounds.size + let titleSize = self.titleNode.calculatedSize + if let iconNode = self.iconNode, let image = iconNode.image { + let titleIconSpacing: CGFloat = 3.0 + iconNode.frame = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height - titleIconSpacing - titleSize.height) / 2.0)), size: image.size) + self.titleNode.frame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - image.size.height - titleIconSpacing - titleSize.height) / 2.0) + image.size.height + titleIconSpacing), size: titleSize) + } else { + self.titleNode.frame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) + } + } +} + +final class ItemListRevealOptionsNode: ASDisplayNode { + private let optionSelected: (ItemListRevealOption) -> Void + + private var options: [ItemListRevealOption] = [] + + private var optionNodes: [ItemListRevealOptionNode] = [] + private var revealOffset: CGFloat = 0.0 + + init(optionSelected: @escaping (ItemListRevealOption) -> Void) { + self.optionSelected = optionSelected + + super.init() + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + func setOptions(_ options: [ItemListRevealOption]) { + if self.options != options { + self.options = options + for node in self.optionNodes { + node.removeFromSupernode() + } + self.optionNodes = options.map { option in + return ItemListRevealOptionNode(title: option.title, icon: option.icon, color: option.color) + } + for node in self.optionNodes { + self.addSubnode(node) + } + self.invalidateCalculatedLayout() + } + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + var maxWidth: CGFloat = 0.0 + for node in self.optionNodes { + let nodeSize = node.measure(constrainedSize) + maxWidth = max(nodeSize.width, maxWidth) + } + return CGSize(width: maxWidth * CGFloat(self.optionNodes.count), height: constrainedSize.height) + } + + override func layout() { + super.layout() + + self.updateNodesLayout(transition: .immediate) + } + + func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + self.revealOffset = offset + self.updateNodesLayout(transition: transition) + } + + private func updateNodesLayout(transition: ContainedViewLayoutTransition) { + let size = self.bounds.size + if size.width.isLessThanOrEqualTo(0.0) || self.optionNodes.isEmpty { + return + } + let basicNodeWidth = floorToScreenPixels(size.width / CGFloat(self.optionNodes.count)) + let lastNodeWidth = size.width - basicNodeWidth * CGFloat(self.optionNodes.count - 1) + let revealFactor = self.revealOffset / size.width + var leftOffset: CGFloat = 0.0 + for i in 0 ..< self.optionNodes.count { + let node = self.optionNodes[i] + let nodeWidth = i == (self.optionNodes.count - 1) ? lastNodeWidth : basicNodeWidth + transition.updateFrame(node: node, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(leftOffset * revealFactor), y: 0.0), size: CGSize(width: nodeWidth, height: size.height))) + leftOffset += nodeWidth + } + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let location = recognizer.location(in: self.view) + for i in 0 ..< self.optionNodes.count { + if self.optionNodes[i].frame.contains(location) { + self.optionSelected(self.options[i]) + break + } + } + } + } +} diff --git a/TelegramUI/ItemListSectionHeaderItem.swift b/TelegramUI/ItemListSectionHeaderItem.swift new file mode 100644 index 0000000000..bd4b901b10 --- /dev/null +++ b/TelegramUI/ItemListSectionHeaderItem.swift @@ -0,0 +1,110 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +class ItemListSectionHeaderItem: ListViewItem, ItemListItem { + let text: String + let sectionId: ItemListSectionId + + let isAlwaysPlain: Bool = true + + init(text: String, sectionId: ItemListSectionId) { + self.text = text + self.sectionId = sectionId + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListSectionHeaderItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply() }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + guard let node = node as? ItemListSectionHeaderItemNode else { + assertionFailure() + return + } + + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } +} + +private let titleFont = Font.regular(14.0) + +class ItemListSectionHeaderItemNode: ListViewItemNode { + private let titleNode: TextNode + + init() { + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.titleNode) + } + + func asyncLayout() -> (_ item: ItemListSectionHeaderItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { item, width, neighbors in + let leftInset: CGFloat = 15.0 + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.text, font: titleFont, textColor: UIColor(0x6d6d72)), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let contentSize: CGSize + var insets = UIEdgeInsets() + let separatorHeight = UIScreenPixel + + contentSize = CGSize(width: width, height: 30.0) + switch neighbors.top { + case .none: + insets.top += 24.0 + case .otherSection: + insets.top += 28.0 + default: + break + } + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + return (layout, { [weak self] in + if let strongSelf = self { + let _ = titleApply() + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 7.0), size: titleLayout.size) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/TelegramUI/ItemListSwitchItem.swift b/TelegramUI/ItemListSwitchItem.swift new file mode 100644 index 0000000000..a1cc35730b --- /dev/null +++ b/TelegramUI/ItemListSwitchItem.swift @@ -0,0 +1,207 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +class ItemListSwitchItem: ListViewItem, ItemListItem { + let title: String + let value: Bool + let sectionId: ItemListSectionId + let style: ItemListStyle + let updated: (Bool) -> Void + + init(title: String, value: Bool, sectionId: ItemListSectionId, style: ItemListStyle, updated: @escaping (Bool) -> Void) { + self.title = title + self.value = value + self.sectionId = sectionId + self.style = style + self.updated = updated + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListSwitchItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply(false) }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? ItemListSwitchItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + var animated = true + if case .None = animation { + animated = false + } + apply(animated) + }) + } + } + } + } + } +} + +private let titleFont = Font.regular(17.0) + +class ItemListSwitchItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + + private let titleNode: TextNode + private var switchNode: SwitchNode + + private var item: ItemListSwitchItem? + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.bottomStripeNode.isLayerBacked = true + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.switchNode = SwitchNode() + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.titleNode) + self.addSubnode(self.switchNode) + } + + override func didLoad() { + super.didLoad() + + (self.switchNode.view as? UISwitch)?.addTarget(self, action: #selector(self.switchValueChanged(_:)), for: .valueChanged) + } + + func asyncLayout() -> (_ item: ItemListSwitchItem, _ width: CGFloat, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { item, width, neighbors in + let sectionInset: CGFloat = 22.0 + let rightInset: CGFloat = 80.0 + + let contentSize: CGSize + let insets: UIEdgeInsets + let separatorHeight = UIScreenPixel + + switch item.style { + case .plain: + contentSize = CGSize(width: width, height: 44.0) + insets = itemListNeighborsPlainInsets(neighbors) + case .blocks: + contentSize = CGSize(width: width, height: 44.0) + insets = itemListNeighborsGroupedInsets(neighbors) + } + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] animated in + if let strongSelf = self { + strongSelf.item = item + + let _ = titleApply() + + let leftInset: CGFloat + + switch item.style { + case .plain: + leftInset = 35.0 + + if strongSelf.backgroundNode.supernode != nil { + strongSelf.backgroundNode.removeFromSupernode() + } + if strongSelf.topStripeNode.supernode != nil { + strongSelf.topStripeNode.removeFromSupernode() + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 0) + } + + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - separatorHeight), size: CGSize(width: width - leftInset, height: separatorHeight)) + case .blocks: + leftInset = 16.0 + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = false + } + let bottomStripeInset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = 16.0 + default: + bottomStripeInset = 0.0 + } + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) + strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + } + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) + if let switchView = strongSelf.switchNode.view as? UISwitch { + if strongSelf.switchNode.bounds.size.width.isZero { + switchView.sizeToFit() + } + let switchSize = switchView.bounds.size + + strongSelf.switchNode.frame = CGRect(origin: CGPoint(x: width - switchSize.width - 15.0, y:6.0), size: switchSize) + if switchView.isOn != item.value { + switchView.setOn(item.value, animated: animated) + } + } + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + @objc func switchValueChanged(_ switchView: UISwitch) { + if let item = self.item { + item.updated(switchView.isOn) + } + } +} diff --git a/TelegramUI/ItemListTextItem.swift b/TelegramUI/ItemListTextItem.swift new file mode 100644 index 0000000000..f46f96c462 --- /dev/null +++ b/TelegramUI/ItemListTextItem.swift @@ -0,0 +1,104 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +class ItemListTextItem: ListViewItem, ItemListItem { + let text: String + let sectionId: ItemListSectionId + + let isAlwaysPlain: Bool = true + + init(text: String, sectionId: ItemListSectionId) { + self.text = text + self.sectionId = sectionId + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListTextItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply() }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + guard let node = node as? ItemListTextItemNode else { + assertionFailure() + return + } + + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } +} + +private let titleFont = Font.regular(14.0) + +class ItemListTextItemNode: ListViewItemNode { + private let titleNode: TextNode + + init() { + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.titleNode) + } + + func asyncLayout() -> (_ item: ItemListTextItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { item, width, neighbors in + let leftInset: CGFloat = 15.0 + let verticalInset: CGFloat = 7.0 + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.text, font: titleFont, textColor: UIColor(0x6d6d72)), nil, 0, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let contentSize: CGSize + let insets: UIEdgeInsets + let separatorHeight = UIScreenPixel + + contentSize = CGSize(width: width, height: titleLayout.size.height + verticalInset + verticalInset) + insets = itemListNeighborsPlainInsets(neighbors) + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + return (layout, { [weak self] in + if let strongSelf = self { + let _ = titleApply() + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset), size: titleLayout.size) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } +} diff --git a/TelegramUI/PeerInfoTextWithLabelItem.swift b/TelegramUI/ItemListTextWithLabelItem.swift similarity index 85% rename from TelegramUI/PeerInfoTextWithLabelItem.swift rename to TelegramUI/ItemListTextWithLabelItem.swift index af8d5029e5..777ffc46a3 100644 --- a/TelegramUI/PeerInfoTextWithLabelItem.swift +++ b/TelegramUI/ItemListTextWithLabelItem.swift @@ -3,13 +3,13 @@ import Display import AsyncDisplayKit import SwiftSignalKit -final class PeerInfoTextWithLabelItem: ListViewItem, PeerInfoItem { +final class ItemListTextWithLabelItem: ListViewItem, ItemListItem { let label: String let text: String let multiline: Bool - let sectionId: PeerInfoItemSectionId + let sectionId: ItemListSectionId - init(label: String, text: String, multiline: Bool, sectionId: PeerInfoItemSectionId) { + init(label: String, text: String, multiline: Bool, sectionId: ItemListSectionId) { self.label = label self.text = text self.multiline = multiline @@ -18,8 +18,8 @@ final class PeerInfoTextWithLabelItem: ListViewItem, PeerInfoItem { func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { async { - let node = PeerInfoTextWithLabelItemNode() - let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let node = ItemListTextWithLabelItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) node.contentSize = layout.contentSize node.insets = layout.insets @@ -31,12 +31,12 @@ final class PeerInfoTextWithLabelItem: ListViewItem, PeerInfoItem { } func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { - if let node = node as? PeerInfoTextWithLabelItemNode { + if let node = node as? ItemListTextWithLabelItemNode { Queue.mainQueue().async { let makeLayout = node.asyncLayout() async { - let (layout, apply) = makeLayout(self, width, peerInfoItemNeighbors(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) Queue.mainQueue().async { completion(layout, { apply() @@ -57,7 +57,7 @@ final class PeerInfoTextWithLabelItem: ListViewItem, PeerInfoItem { private let labelFont = Font.regular(14.0) private let textFont = Font.regular(17.0) -class PeerInfoTextWithLabelItemNode: ListViewItemNode { +class ItemListTextWithLabelItemNode: ListViewItemNode { let labelNode: TextNode let textNode: TextNode let separatorNode: ASDisplayNode @@ -85,12 +85,12 @@ class PeerInfoTextWithLabelItemNode: ListViewItemNode { self.addSubnode(self.textNode) } - func asyncLayout() -> (_ item: PeerInfoTextWithLabelItem, _ width: CGFloat, _ insets: PeerInfoItemNeighbors) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ItemListTextWithLabelItem, _ width: CGFloat, _ insets: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let makeLabelLayout = TextNode.asyncLayout(self.labelNode) let makeTextLayout = TextNode.asyncLayout(self.textNode) return { item, width, neighbors in - let insets = peerInfoItemNeighborsPlainInsets(neighbors) + let insets = itemListNeighborsPlainInsets(neighbors) let leftInset: CGFloat = 35.0 let (labelLayout, labelApply) = makeLabelLayout(NSAttributedString(string: item.label, font: labelFont, textColor: UIColor(0x007ee5)), nil, 1, .end, CGSize(width: width - leftInset - 8.0, height: CGFloat.greatestFiniteMagnitude), nil) diff --git a/TelegramUI/ListController.swift b/TelegramUI/ListController.swift index bd2c380451..d212807b50 100644 --- a/TelegramUI/ListController.swift +++ b/TelegramUI/ListController.swift @@ -12,6 +12,14 @@ public class ListController: ViewController { } } + override public init(navigationBar: NavigationBar = NavigationBar()) { + super.init(navigationBar: navigationBar) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override public func loadDisplayNode() { self.displayNode = ListControllerNode() diff --git a/TelegramUI/MediaNavigationAccessoryItemListNode.swift b/TelegramUI/MediaNavigationAccessoryItemListNode.swift index a40a347b12..4871b6d772 100644 --- a/TelegramUI/MediaNavigationAccessoryItemListNode.swift +++ b/TelegramUI/MediaNavigationAccessoryItemListNode.swift @@ -64,7 +64,7 @@ final class MediaNavigationAccessoryItemListNode: ASDisplayNode { } } } - }, 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 }, requestMessageActionCallback: { _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _ in }, openHashtag: { _ in }, updateInputState: { _ in }) let listNode = ChatHistoryListNode(account: account, peerId: updatedPlaylistPeerId, tagMask: .Music, messageId: nil, controllerInteraction: controllerInteraction, mode: .list) listNode.preloadPages = true diff --git a/TelegramUI/NotificationSoundSelection.swift b/TelegramUI/NotificationSoundSelection.swift new file mode 100644 index 0000000000..27cd16c2db --- /dev/null +++ b/TelegramUI/NotificationSoundSelection.swift @@ -0,0 +1,189 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private struct NotificationSoundSelectionArguments { + let account: Account + + let selectSound: (PeerMessageSound) -> Void + let complete: () -> Void + let cancel: () -> Void +} + +private enum NotificationSoundSelectionSection: Int32 { + case modern + case classic +} + +private struct NotificationSoundSelectionState: Equatable { + let selectedSound: PeerMessageSound + + static func ==(lhs: NotificationSoundSelectionState, rhs: NotificationSoundSelectionState) -> Bool { + return lhs.selectedSound == rhs.selectedSound + } +} + +private enum NotificationSoundSelectionEntry: ItemListNodeEntry { + case modernHeader + case classicHeader + case none(section: NotificationSoundSelectionSection, selected: Bool) + case sound(section: NotificationSoundSelectionSection, index: Int32, sound: PeerMessageSound, selected: Bool) + + var section: ItemListSectionId { + switch self { + case .modernHeader: + return NotificationSoundSelectionSection.modern.rawValue + case .classicHeader: + return NotificationSoundSelectionSection.classic.rawValue + case let .none(section, _): + return section.rawValue + case let .sound(section, _, _, _): + return section.rawValue + } + } + + var stableId: Int32 { + switch self { + case .modernHeader: + return 0 + case .classicHeader: + return 1000 + case let .none(section, _): + switch section { + case .modern: + return 1 + case .classic: + return 1001 + } + case let .sound(section, index, _, _): + switch section { + case .modern: + return 2 + index + case .classic: + return 1002 + index + } + } + } + + static func ==(lhs: NotificationSoundSelectionEntry, rhs: NotificationSoundSelectionEntry) -> Bool { + switch lhs { + case .modernHeader, .classicHeader: + if lhs.stableId == rhs.stableId { + return true + } else { + return false + } + case let .none(section, selected): + if case .none(section, selected) = rhs { + return true + } else { + return false + } + case let .sound(section, index, name, selected): + if case .sound(section, index, name, selected) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: NotificationSoundSelectionEntry, rhs: NotificationSoundSelectionEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: NotificationSoundSelectionArguments) -> ListViewItem { + switch self { + case .modernHeader: + return ItemListSectionHeaderItem(text: "ALERT TONES", sectionId: self.section) + case .classicHeader: + return ItemListSectionHeaderItem(text: "ALERT TONES", sectionId: self.section) + case let .none(_, selected): + return ItemListCheckboxItem(title: localizedPeerNotificationSoundString(.none), checked: selected, zeroSeparatorInsets: true, sectionId: self.section, action: { + arguments.selectSound(.none) + }) + case let .sound(_, _, sound, selected): + return ItemListCheckboxItem(title: localizedPeerNotificationSoundString(sound), checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.selectSound(sound) + }) + } + } +} + +private func notificationsAndSoundsEntries(state: NotificationSoundSelectionState) -> [NotificationSoundSelectionEntry] { + var entries: [NotificationSoundSelectionEntry] = [] + + entries.append(.modernHeader) + entries.append(.none(section: .modern, selected: state.selectedSound == .none)) + for i in 0 ..< 12 { + let sound: PeerMessageSound = .bundledModern(id: Int32(i)) + entries.append(.sound(section: .modern, index: Int32(i), sound: sound, selected: sound == state.selectedSound)) + } + + entries.append(.classicHeader) + for i in 0 ..< 8 { + let sound: PeerMessageSound = .bundledClassic(id: Int32(i)) + entries.append(.sound(section: .classic, index: Int32(i), sound: sound, selected: sound == state.selectedSound)) + } + + return entries +} + +public func notificationSoundSelectionController(account: Account, isModal: Bool, currentSound: PeerMessageSound) -> (ViewController, Signal) { + let statePromise = ValuePromise(NotificationSoundSelectionState(selectedSound: currentSound), ignoreRepeated: true) + let stateValue = Atomic(value: NotificationSoundSelectionState(selectedSound: currentSound)) + let updateState: ((NotificationSoundSelectionState) -> NotificationSoundSelectionState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var completeImpl: (() -> Void)? + var cancelImpl: (() -> Void)? + + let arguments = NotificationSoundSelectionArguments(account: account, selectSound: { sound in + updateState { state in + return NotificationSoundSelectionState(selectedSound: sound) + } + }, complete: { + completeImpl?() + }, cancel: { + cancelImpl?() + }) + + let leftNavigationButton = ItemListNavigationButton(title: "Cancel", style: .regular, enabled: true, action: { + arguments.cancel() + }) + + let rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + arguments.complete() + }) + + let signal = statePromise.get() + |> map { state -> (ItemListControllerState, (ItemListNodeState, NotificationSoundSelectionEntry.ItemGenerationArguments)) in + + let controllerState = ItemListControllerState(title: "Text Tone", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) + let listState = ItemListNodeState(entries: notificationsAndSoundsEntries(state: state), style: .blocks) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(signal) + + let result = Promise() + + completeImpl = { [weak controller] in + let sound = stateValue.with { state in + return state.selectedSound + } + result.set(.single(sound)) + controller?.dismiss() + } + + cancelImpl = { [weak controller] in + result.set(.single(nil)) + controller?.dismiss() + } + + return (controller, result.get()) +} diff --git a/TelegramUI/NotificationsAndSounds.swift b/TelegramUI/NotificationsAndSounds.swift new file mode 100644 index 0000000000..f159e4867a --- /dev/null +++ b/TelegramUI/NotificationsAndSounds.swift @@ -0,0 +1,429 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class NotificationsAndSoundsArguments { + let account: Account + let presentController: (ViewController, ViewControllerPresentationArguments) -> Void + let soundSelectionDisposable: MetaDisposable + + let updateMessageAlerts: (Bool) -> Void + let updateMessagePreviews: (Bool) -> Void + let updateMessageSound: (PeerMessageSound) -> Void + + let updateGroupAlerts: (Bool) -> Void + let updateGroupPreviews: (Bool) -> Void + let updateGroupSound: (PeerMessageSound) -> Void + + let updateInAppSounds: (Bool) -> Void + let updateInAppVibration: (Bool) -> Void + let updateInAppPreviews: (Bool) -> Void + + let resetNotifications: () -> Void + + init(account: Account, presentController: @escaping (ViewController, ViewControllerPresentationArguments) -> Void, soundSelectionDisposable: MetaDisposable, updateMessageAlerts: @escaping (Bool) -> Void, updateMessagePreviews: @escaping (Bool) -> Void, updateMessageSound: @escaping (PeerMessageSound) -> Void, updateGroupAlerts: @escaping (Bool) -> Void, updateGroupPreviews: @escaping (Bool) -> Void, updateGroupSound: @escaping (PeerMessageSound) -> Void, updateInAppSounds: @escaping (Bool) -> Void, updateInAppVibration: @escaping (Bool) -> Void, updateInAppPreviews: @escaping (Bool) -> Void, resetNotifications: @escaping () -> Void) { + self.account = account + self.presentController = presentController + self.soundSelectionDisposable = soundSelectionDisposable + self.updateMessageAlerts = updateMessageAlerts + self.updateMessagePreviews = updateMessagePreviews + self.updateMessageSound = updateMessageSound + self.updateGroupAlerts = updateGroupAlerts + self.updateGroupPreviews = updateGroupPreviews + self.updateGroupSound = updateGroupSound + self.updateInAppSounds = updateInAppSounds + self.updateInAppVibration = updateInAppVibration + self.updateInAppPreviews = updateInAppPreviews + self.resetNotifications = resetNotifications + } +} + +private enum NotificationsAndSoundsSection: Int32 { + case messages + case groups + case inApp + case reset +} + +private enum NotificationsAndSoundsEntry: ItemListNodeEntry { + case messageHeader + case messageAlerts(Bool) + case messagePreviews(Bool) + case messageSound(PeerMessageSound) + case messageNotice + + case groupHeader + case groupAlerts(Bool) + case groupPreviews(Bool) + case groupSound(PeerMessageSound) + case groupNotice + + case inAppHeader + case inAppSounds(Bool) + case inAppVibrate(Bool) + case inAppPreviews(Bool) + + case reset + case resetNotice + + var section: ItemListSectionId { + switch self { + case .messageHeader, .messageAlerts, .messagePreviews, .messageSound, .messageNotice: + return NotificationsAndSoundsSection.messages.rawValue + case .groupHeader, .groupAlerts, .groupPreviews, .groupSound, .groupNotice: + return NotificationsAndSoundsSection.groups.rawValue + case .inAppHeader, .inAppSounds, .inAppVibrate, .inAppPreviews: + return NotificationsAndSoundsSection.inApp.rawValue + case .reset, .resetNotice: + return NotificationsAndSoundsSection.reset.rawValue + } + } + + var stableId: Int32 { + switch self { + case .messageHeader: + return 0 + case .messageAlerts: + return 1 + case .messagePreviews: + return 2 + case .messageSound: + return 3 + case .messageNotice: + return 4 + case .groupHeader: + return 5 + case .groupAlerts: + return 6 + case .groupPreviews: + return 7 + case .groupSound: + return 8 + case .groupNotice: + return 9 + case .inAppHeader: + return 10 + case .inAppSounds: + return 11 + case .inAppVibrate: + return 12 + case .inAppPreviews: + return 13 + case .reset: + return 14 + case .resetNotice: + return 15 + } + } + + static func ==(lhs: NotificationsAndSoundsEntry, rhs: NotificationsAndSoundsEntry) -> Bool { + switch lhs { + case .messageHeader: + if case .messageHeader = rhs { + return true + } else { + return false + } + case let .messageAlerts(value): + if case .messageAlerts(value) = rhs { + return true + } else { + return false + } + case let .messagePreviews(value): + if case .messagePreviews(value) = rhs { + return true + } else { + return false + } + case let .messageSound(value): + if case .messageSound(value) = rhs { + return true + } else { + return false + } + case .messageNotice: + if case .messageNotice = rhs { + return true + } else { + return false + } + case .groupHeader: + if case .groupHeader = rhs { + return true + } else { + return false + } + case let .groupAlerts(value): + if case .groupAlerts(value) = rhs { + return true + } else { + return false + } + case let .groupPreviews(value): + if case .groupPreviews(value) = rhs { + return true + } else { + return false + } + case let .groupSound(value): + if case .groupSound(value) = rhs { + return true + } else { + return false + } + case .groupNotice: + if case .groupNotice = rhs { + return true + } else { + return false + } + case .inAppHeader: + if case .inAppHeader = rhs { + return true + } else { + return false + } + case let .inAppSounds(value): + if case .inAppSounds(value) = rhs { + return true + } else { + return false + } + case let .inAppVibrate(value): + if case .inAppVibrate(value) = rhs { + return true + } else { + return false + } + case let .inAppPreviews(value): + if case .inAppPreviews(value) = rhs { + return true + } else { + return false + } + case .reset: + if case .reset = rhs { + return true + } else { + return false + } + case .resetNotice: + if case .resetNotice = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: NotificationsAndSoundsEntry, rhs: NotificationsAndSoundsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: NotificationsAndSoundsArguments) -> ListViewItem { + switch self { + case .messageHeader: + return ItemListSectionHeaderItem(text: "MESSAGE NOTIFICATIONS", sectionId: self.section) + case let .messageAlerts(value): + return ItemListSwitchItem(title: "Alert", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateMessageAlerts(updatedValue) + }) + case let .messagePreviews(value): + return ItemListSwitchItem(title: "Message Preview", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateMessagePreviews(updatedValue) + }) + case let .messageSound(value): + return ItemListDisclosureItem(title: "Sound", label: localizedPeerNotificationSoundString(value), sectionId: self.section, style: .blocks, action: { + let (controller, result) = notificationSoundSelectionController(account: arguments.account, isModal: true, currentSound: value) + arguments.presentController(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + arguments.soundSelectionDisposable.set(result.start(next: { [weak arguments] value in + if let value = value { + arguments?.updateMessageSound(value) + } + })) + }) + case .messageNotice: + return ItemListTextItem(text: "You can set custom notifications for specific users on their info page.", sectionId: self.section) + case .groupHeader: + return ItemListSectionHeaderItem(text: "GROUP NOTIFICATIONS", sectionId: self.section) + case let .groupAlerts(value): + return ItemListSwitchItem(title: "Alert", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateGroupAlerts(updatedValue) + }) + case let .groupPreviews(value): + return ItemListSwitchItem(title: "Message Preview", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateGroupPreviews(updatedValue) + }) + case let .groupSound(value): + return ItemListDisclosureItem(title: "Sound", label: localizedPeerNotificationSoundString(value), sectionId: self.section, style: .blocks, action: { + let (controller, result) = notificationSoundSelectionController(account: arguments.account, isModal: true, currentSound: value) + arguments.presentController(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + arguments.soundSelectionDisposable.set(result.start(next: { [weak arguments] value in + if let value = value { + arguments?.updateMessageSound(value) + } + })) + }) + case .groupNotice: + return ItemListTextItem(text: "You can set custom notifications for specific groups on their info page.", sectionId: self.section) + case .inAppHeader: + return ItemListSectionHeaderItem(text: "IN-APP NOTIFICATIONS", sectionId: self.section) + case let .inAppSounds(value): + return ItemListSwitchItem(title: "In-App Sounds", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateInAppSounds(updatedValue) + }) + case let .inAppVibrate(value): + return ItemListSwitchItem(title: "In-App Vibrate", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateInAppVibration(updatedValue) + }) + case let .inAppPreviews(value): + return ItemListSwitchItem(title: "In-App Preview", value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in + arguments.updateInAppPreviews(updatedValue) + }) + case .reset: + return ItemListActionItem(title: "Reset All Notifications", kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.resetNotifications() + }) + case .resetNotice: + return ItemListTextItem(text: "Undo all custom notification settings for all your contacts and groups.", sectionId: self.section) + } + } +} + +private func notificationsAndSoundsEntries(globalSettings: GlobalNotificationSettingsSet, inAppSettings: InAppNotificationSettings) -> [NotificationsAndSoundsEntry] { + var entries: [NotificationsAndSoundsEntry] = [] + + entries.append(.messageHeader) + entries.append(.messageAlerts(globalSettings.privateChats.enabled)) + entries.append(.messagePreviews(globalSettings.privateChats.displayPreviews)) + entries.append(.messageSound(globalSettings.privateChats.sound)) + entries.append(.messageNotice) + + entries.append(.groupHeader) + entries.append(.groupAlerts(globalSettings.groupChats.enabled)) + entries.append(.groupPreviews(globalSettings.groupChats.displayPreviews)) + entries.append(.groupSound(globalSettings.groupChats.sound)) + entries.append(.groupNotice) + + entries.append(.inAppHeader) + entries.append(.inAppSounds(inAppSettings.playSounds)) + entries.append(.inAppVibrate(inAppSettings.vibrate)) + entries.append(.inAppPreviews(inAppSettings.displayPreviews)) + + entries.append(.reset) + entries.append(.resetNotice) + + return entries +} + +public func notificationsAndSoundsController(account: Account) -> ViewController { + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + + let arguments = NotificationsAndSoundsArguments(account: account, presentController: { controller, arguments in + presentControllerImpl?(controller, arguments) + }, soundSelectionDisposable: MetaDisposable(), updateMessageAlerts: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedPrivateChats { + return $0.withUpdatedEnabled(value) + } + }).start() + }, updateMessagePreviews: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedPrivateChats { + return $0.withUpdatedDisplayPreviews(value) + } + }).start() + }, updateMessageSound: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedPrivateChats { + return $0.withUpdatedSound(value) + } + }).start() + }, updateGroupAlerts: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedGroupChats { + return $0.withUpdatedEnabled(value) + } + }).start() + }, updateGroupPreviews: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedGroupChats { + return $0.withUpdatedDisplayPreviews(value) + } + }).start() + }, updateGroupSound: {value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedGroupChats { + return $0.withUpdatedSound(value) + } + }).start() + }, updateInAppSounds: { value in + let _ = updateInAppNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedPlaySounds(value) + }).start() + }, updateInAppVibration: { value in + let _ = updateInAppNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedVibrate(value) + }).start() + }, updateInAppPreviews: { value in + let _ = updateInAppNotificationSettingsInteractively(postbox: account.postbox, { settings in + return settings.withUpdatedDisplayPreviews(value) + }).start() + }, resetNotifications: { + let actionSheet = ActionSheetController() + actionSheet.setItemGroups([ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Reset", color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + let modifyPeers = account.postbox.modify { modifier -> Void in + modifier.resetAllPeerNotificationSettings(TelegramPeerNotificationSettings.defaultSettings) + } + let updateGlobal = updateGlobalNotificationSettingsInteractively(postbox: account.postbox, { _ in + return GlobalNotificationSettingsSet.defaultSettings + }) + let reset = resetPeerNotificationSettings(network: account.network) + let signal = combineLatest(modifyPeers, updateGlobal, reset) + let _ = signal.start() + }) + ]), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + presentControllerImpl?(actionSheet, nil) + }) + + let preferences = account.postbox.preferencesView(keys: [PreferencesKeys.globalNotifications, ApplicationSpecificPreferencesKeys.inAppNotificationSettings]) + + let signal = preferences + |> map { view -> (ItemListControllerState, (ItemListNodeState, NotificationsAndSoundsEntry.ItemGenerationArguments)) in + + let viewSettings: GlobalNotificationSettingsSet + if let settings = view.values[PreferencesKeys.globalNotifications] as? GlobalNotificationSettings { + viewSettings = settings.effective + } else { + viewSettings = GlobalNotificationSettingsSet.defaultSettings + } + + let inAppSettings: InAppNotificationSettings + if let settings = view.values[ApplicationSpecificPreferencesKeys.inAppNotificationSettings] as? InAppNotificationSettings { + inAppSettings = settings + } else { + inAppSettings = InAppNotificationSettings.defaultSettings + } + + let controllerState = ItemListControllerState(title: "Notifications", leftNavigationButton: nil, rightNavigationButton: nil) + let listState = ItemListNodeState(entries: notificationsAndSoundsEntries(globalSettings: viewSettings, inAppSettings: inAppSettings), style: .blocks) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(signal) + presentControllerImpl = { [weak controller] c, a in + controller?.present(c, in: .window, with: a) + } + return controller +} diff --git a/TelegramUI/PeerInfoController.swift b/TelegramUI/PeerInfoController.swift index 3f6fb51ea3..eee70b434f 100644 --- a/TelegramUI/PeerInfoController.swift +++ b/TelegramUI/PeerInfoController.swift @@ -88,8 +88,9 @@ public final class PeerInfoController: ListController { private let transitionDisposable = MetaDisposable() private let changeSettingsDisposable = MetaDisposable() + private let additionalInfoDisposable = MetaDisposable() - private var currentListStyle: PeerInfoListStyle = .plain + private var currentListStyle: ItemListStyle = .plain private var state = PeerInfoEquatableState(state: nil) { didSet { @@ -119,6 +120,7 @@ public final class PeerInfoController: ListController { deinit { self.transitionDisposable.dispose() self.changeSettingsDisposable.dispose() + self.additionalInfoDisposable.dispose() } override public func displayNodeDidLoad() { @@ -150,7 +152,7 @@ public final class PeerInfoController: ListController { } else { muteState = .muted(until: Int32(Date().timeIntervalSince1970) + muteUntil) } - strongSelf.changeSettingsDisposable.set(changePeerNotificationSettings(account: strongSelf.account, peerId: strongSelf.peerId, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.appDefault)).start()) + strongSelf.changeSettingsDisposable.set(changePeerNotificationSettings(account: strongSelf.account, peerId: strongSelf.peerId, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.bundledModern(id: 0))).start()) } } controller.setItemGroups([ @@ -194,20 +196,26 @@ public final class PeerInfoController: ListController { let account = self.account let transition = combineLatest(account.viewTracker.peerView(self.peerId), self.statePromise.get() |> distinctUntilChanged) - |> map { view, state -> (PeerInfoEntryTransition, PeerInfoListStyle, Bool, Bool, PeerInfoNavigationButton?, PeerInfoNavigationButton?) in + |> map { view, state -> (PeerInfoEntryTransition, ItemListStyle, Bool, Bool, PeerInfoNavigationButton?, PeerInfoNavigationButton?) in let infoEntries = peerInfoEntries(view: view, state: state.state) let entries = infoEntries.entries.map { PeerInfoSortableEntry(entry: $0) } assert(entries == entries.sorted()) let previous = previousEntries.swap(entries) - let style: PeerInfoListStyle - if let group = view.peers[view.peerId] as? TelegramGroup { + let style: ItemListStyle + if let _ = view.peers[view.peerId] as? TelegramGroup { style = .blocks } else if let channel = view.peers[view.peerId] as? TelegramChannel, case .group = channel.info { style = .blocks } else { style = .plain } - return (preparedPeerInfoEntryTransition(account: account, from: previous ?? [], to: entries, interaction: interaction), style, previous == nil, previous != nil, infoEntries.leftNavigationButton, infoEntries.rightNavigationButton) + let animated: Bool + if let previous = previous { + animated = (entries.count - previous.count) < 20 + } else { + animated = false + } + return (preparedPeerInfoEntryTransition(account: account, from: previous ?? [], to: entries, interaction: interaction), style, previous == nil, animated, infoEntries.leftNavigationButton, infoEntries.rightNavigationButton) } |> deliverOnMainQueue @@ -251,9 +259,13 @@ public final class PeerInfoController: ListController { } } })) + + if self.peerId.namespace == Namespaces.Peer.CloudChannel { + self.additionalInfoDisposable.set(self.account.viewTracker.updatedCachedChannelParticipants(self.peerId, forceImmediateUpdate: true).start()) + } } - private func enqueueTransition(_ transition: PeerInfoEntryTransition, style: PeerInfoListStyle, firstTime: Bool, animated: Bool) { + private func enqueueTransition(_ transition: PeerInfoEntryTransition, style: ItemListStyle, firstTime: Bool, animated: Bool) { if self.currentListStyle != style { self.currentListStyle = style switch style { diff --git a/TelegramUI/PeerInfoEntries.swift b/TelegramUI/PeerInfoEntries.swift index ae4b233933..6b9bb7498f 100644 --- a/TelegramUI/PeerInfoEntries.swift +++ b/TelegramUI/PeerInfoEntries.swift @@ -3,12 +3,6 @@ import Postbox import TelegramCore import Display -protocol PeerInfoSection { - var rawValue: UInt32 { get } - func isEqual(to: PeerInfoSection) -> Bool - func isOrderedBefore(_ section: PeerInfoSection) -> Bool -} - protocol PeerInfoEntryStableId { func isEqual(to: PeerInfoEntryStableId) -> Bool var hashValue: Int { get } @@ -31,7 +25,7 @@ struct IntPeerInfoEntryStableId: PeerInfoEntryStableId { } protocol PeerInfoEntry { - var section: PeerInfoSection { get } + var section: ItemListSectionId { get } var stableId: PeerInfoEntryStableId { get } func isEqual(to: PeerInfoEntry) -> Bool func isOrderedBefore(_ entry: PeerInfoEntry) -> Bool @@ -56,15 +50,17 @@ struct PeerInfoEntries { func peerInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { if let user = view.peers[view.peerId] as? TelegramUser { return userInfoEntries(view: view, state: state) + } else if let secretChat = view.peers[view.peerId] as? TelegramSecretChat { + return userInfoEntries(view: view, state: state) } else if let channel = view.peers[view.peerId] as? TelegramChannel { switch channel.info { case .broadcast: return channelBroadcastInfoEntries(view: view) case .group: - return groupInfoEntries(view: view) + return groupInfoEntries(view: view, state: state) } } else if let group = view.peers[view.peerId] as? TelegramGroup { - return groupInfoEntries(view: view) + return groupInfoEntries(view: view, state: state) } return PeerInfoEntries(entries: [], leftNavigationButton: nil, rightNavigationButton: nil) } diff --git a/TelegramUI/PeerInfoItem.swift b/TelegramUI/PeerInfoItem.swift deleted file mode 100644 index 83e0afbe90..0000000000 --- a/TelegramUI/PeerInfoItem.swift +++ /dev/null @@ -1,65 +0,0 @@ - -typealias PeerInfoItemSectionId = UInt32 - -protocol PeerInfoItem { - var sectionId: PeerInfoItemSectionId { get } -} - -enum PeerInfoItemNeighbor { - case none - case otherSection - case sameSection -} - -struct PeerInfoItemNeighbors { - let top: PeerInfoItemNeighbor - let bottom: PeerInfoItemNeighbor -} - -func peerInfoItemNeighbors(item: PeerInfoItem, topItem: PeerInfoItem?, bottomItem: PeerInfoItem?) -> PeerInfoItemNeighbors { - let topNeighbor: PeerInfoItemNeighbor - if let topItem = topItem { - if topItem.sectionId != item.sectionId { - topNeighbor = .otherSection - } else { - topNeighbor = .sameSection - } - } else { - topNeighbor = .none - } - - let bottomNeighbor: PeerInfoItemNeighbor - if let bottomItem = bottomItem { - if bottomItem.sectionId != item.sectionId { - bottomNeighbor = .otherSection - } else { - bottomNeighbor = .sameSection - } - } else { - bottomNeighbor = .none - } - - return PeerInfoItemNeighbors(top: topNeighbor, bottom: bottomNeighbor) -} - -enum PeerInfoListStyle { - case plain - case blocks -} - -func peerInfoItemNeighborsPlainInsets(_ neighbors: PeerInfoItemNeighbors) -> UIEdgeInsets { - var insets = UIEdgeInsets() - switch neighbors.top { - case .otherSection: - insets.top += 22.0 - case .none, .sameSection: - break - } - switch neighbors.bottom { - case .none: - insets.bottom += 22.0 - case .otherSection, .sameSection: - break - } - return insets -} diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index 7dfcc4d3a7..aaed679037 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -126,7 +126,7 @@ public class PeerMediaCollectionController: ViewController { } } } - }, openPeer: { [weak self] id, navigation in + }, openSecretMessagePreview: { _ in }, closeSecretMessagePreview: { }, openPeer: { [weak self] id, navigation in if let strongSelf = self { if let id = id { (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) @@ -181,7 +181,7 @@ public class PeerMediaCollectionController: ViewController { } }, sendMessage: { _ in },sendSticker: { _ in - }, requestMessageActionCallback: { _, _ in + }, requestMessageActionCallback: { _ in }, openUrl: { _ in }, shareCurrentLocation: { }, shareAccountContact: { @@ -210,6 +210,7 @@ public class PeerMediaCollectionController: ViewController { }, botSwitchChatWithPayload: { _ in }, beginAudioRecording: { }, finishAudioRecording: { _ in + }, setupMessageAutoremoveTimeout: { }, statuses: nil) self.updateInterfaceState(animated: false, { return $0 }) diff --git a/TelegramUI/PeerNotificationSoundStrings.swift b/TelegramUI/PeerNotificationSoundStrings.swift new file mode 100644 index 0000000000..5591a7454b --- /dev/null +++ b/TelegramUI/PeerNotificationSoundStrings.swift @@ -0,0 +1,45 @@ +import Foundation +import TelegramCore + +private let modernSounds: [String] = [ + "Note", + "Aurora", + "Bamboo", + "Chord", + "Circles", + "Complete", + "Hello", + "Input", + "Keys", + "Popcorn", + "Pulse", + "Synth" +] + +private let classicSounds: [String] = [ + "Tri-tone", + "Tremolo", + "Alert", + "Bell", + "Calypso", + "Chime", + "Glass", + "Telegraph" +] + +func localizedPeerNotificationSoundString(_ sound: PeerMessageSound) -> String { + switch sound { + case .none: + return "None" + case let .bundledModern(id): + if id >= 0 && Int(id) < modernSounds.count { + return modernSounds[Int(id)] + } + return "Sound \(id)" + case let .bundledClassic(id): + if id >= 0 && Int(id) < classicSounds.count { + return classicSounds[Int(id)] + } + return "Sound \(id)" + } +} diff --git a/TelegramUI/PeerSelectionController.swift b/TelegramUI/PeerSelectionController.swift index ae245365b5..e410ece992 100644 --- a/TelegramUI/PeerSelectionController.swift +++ b/TelegramUI/PeerSelectionController.swift @@ -65,7 +65,7 @@ public final class PeerSelectionController: ViewController { if let strongSelf = self { let storedPeer = strongSelf.account.postbox.modify { modifier -> Void in if modifier.getPeer(peer.id) == nil { - modifier.updatePeers([peer], update: { previousPeer, updatedPeer in + updatePeers(modifier: modifier, peers: [peer], update: { previousPeer, updatedPeer in return updatedPeer }) } @@ -82,7 +82,7 @@ public final class PeerSelectionController: ViewController { if let strongSelf = self { let storedPeer = strongSelf.account.postbox.modify { modifier -> Void in if modifier.getPeer(peer.id) == nil { - modifier.updatePeers([peer], update: { previousPeer, updatedPeer in + updatePeers(modifier: modifier, peers: [peer], update: { previousPeer, updatedPeer in return updatedPeer }) } diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index 103f17f93c..e352e1432a 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -472,6 +472,117 @@ func chatMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(Tr } } +func chatSecretPhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + let signal = chatMessagePhotoDatas(account: account, photo: photo) + + return signal |> map { (thumbnailData, fullSizeData, fullSizeComplete) in + return { arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + let drawingRect = arguments.drawingRect + var fittedSize = arguments.imageSize + if abs(fittedSize.width - arguments.boundingSize.width).isLessThanOrEqualTo(CGFloat(1.0)) { + fittedSize.width = arguments.boundingSize.width + } + if abs(fittedSize.height - arguments.boundingSize.height).isLessThanOrEqualTo(CGFloat(1.0)) { + fittedSize.height = arguments.boundingSize.height + } + + let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize) + + var blurredImage: UIImage? + + if let fullSizeData = fullSizeData { + if fullSizeComplete { + 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) { + let thumbnailSize = CGSize(width: image.width, height: image.height) + let thumbnailContextSize = thumbnailSize.aspectFilled(CGSize(width: 20.0, height: 20.0)) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + thumbnailContext.withFlippedContext { c in + c.interpolationQuality = .none + c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) + } + telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + let thumbnailContext2Size = thumbnailSize.aspectFitted(CGSize(width: 100.0, height: 100.0)) + let thumbnailContext2 = DrawingContext(size: thumbnailContext2Size, scale: 1.0) + thumbnailContext2.withFlippedContext { c in + c.interpolationQuality = .none + if let image = thumbnailContext.generateImage()?.cgImage { + c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContext2Size)) + } + } + telegramFastBlur(Int32(thumbnailContext2Size.width), Int32(thumbnailContext2Size.height), Int32(thumbnailContext2.bytesPerRow), thumbnailContext2.bytes) + + blurredImage = thumbnailContext2.generateImage() + } + }/* 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 + } + }*/ + } + + if blurredImage == nil { + if let thumbnailData = thumbnailData, let imageSource = CGImageSourceCreateWithData(thumbnailData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) { + let thumbnailSize = CGSize(width: image.width, height: image.height) + let thumbnailContextSize = thumbnailSize.aspectFilled(CGSize(width: 20.0, height: 20.0)) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + thumbnailContext.withFlippedContext { c in + c.interpolationQuality = .none + c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) + } + telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + let thumbnailContext2Size = thumbnailSize.aspectFitted(CGSize(width: 100.0, height: 100.0)) + let thumbnailContext2 = DrawingContext(size: thumbnailContext2Size, scale: 1.0) + thumbnailContext2.withFlippedContext { c in + c.interpolationQuality = .none + if let image = thumbnailContext.generateImage()?.cgImage { + c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContext2Size)) + } + } + telegramFastBlur(Int32(thumbnailContext2Size.width), Int32(thumbnailContext2Size.height), Int32(thumbnailContext2.bytesPerRow), thumbnailContext2.bytes) + + blurredImage = thumbnailContext2.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 blurredImage = blurredImage, let cgImage = blurredImage.cgImage { + c.interpolationQuality = .low + c.draw(cgImage, in: fittedRect) + } + + if !arguments.insets.left.isEqual(to: 0.0) { + c.clear(CGRect(origin: CGPoint(), size: CGSize(width: arguments.insets.left, height: context.size.height))) + } + if !arguments.insets.right.isEqual(to: 0.0) { + c.clear(CGRect(origin: CGPoint(x: context.size.width - arguments.insets.right, y: 0.0), size: CGSize(width: arguments.insets.right, height: context.size.height))) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } +} + func mediaGridMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessagePhotoDatas(account: account, photo: photo, fullRepresentationSize: CGSize(width: 127.0, height: 127.0), autoFetchFullSize: true) @@ -834,6 +945,133 @@ func chatMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(Tra } } +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 thumbnail = Signal { subscriber in + let fetchedDisposable = fetchedThumbnail.start() + let thumbnailDisposable = account.postbox.mediaBox.resourceData(thumbnailResource).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() + } + } + return thumbnail + } else { + return .single(nil) + } +} + +func chatSecretMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + let signal = chatSecretMessageVideoData(account: account, file: video) + + return signal |> map { thumbnailData in + return { arguments in + 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 blurredImage: UIImage? + + if blurredImage == nil { + if let thumbnailData = thumbnailData, let imageSource = CGImageSourceCreateWithData(thumbnailData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) { + let thumbnailSize = CGSize(width: image.width, height: image.height) + let thumbnailContextSize = thumbnailSize.aspectFilled(CGSize(width: 20.0, height: 20.0)) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + thumbnailContext.withFlippedContext { c in + c.interpolationQuality = .none + c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) + } + telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + let thumbnailContext2Size = thumbnailSize.aspectFitted(CGSize(width: 100.0, height: 100.0)) + let thumbnailContext2 = DrawingContext(size: thumbnailContext2Size, scale: 1.0) + thumbnailContext2.withFlippedContext { c in + c.interpolationQuality = .none + if let image = thumbnailContext.generateImage()?.cgImage { + c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContext2Size)) + } + } + telegramFastBlur(Int32(thumbnailContext2Size.width), Int32(thumbnailContext2Size.height), Int32(thumbnailContext2.bytesPerRow), thumbnailContext2.bytes) + + blurredImage = thumbnailContext2.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 blurredImage = blurredImage, let cgImage = blurredImage.cgImage { + c.interpolationQuality = .low + c.draw(cgImage, in: fittedRect) + } + + if !arguments.insets.left.isEqual(to: 0.0) { + c.clear(CGRect(origin: CGPoint(), size: CGSize(width: arguments.insets.left, height: context.size.height))) + } + if !arguments.insets.right.isEqual(to: 0.0) { + c.clear(CGRect(origin: CGPoint(x: context.size.width - arguments.insets.right, y: 0.0), size: CGSize(width: arguments.insets.right, height: context.size.height))) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } +} + func chatMessageImageFile(account: Account, file: TelegramMediaFile, progressive: Bool = false) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessageFileDatas(account: account, file: file, progressive: progressive) diff --git a/TelegramUI/PreferencesKeys.swift b/TelegramUI/PreferencesKeys.swift new file mode 100644 index 0000000000..e621b93c3b --- /dev/null +++ b/TelegramUI/PreferencesKeys.swift @@ -0,0 +1,10 @@ +import Foundation +import TelegramCore + +private enum ApplicationSpecificPreferencesKeyValues: Int32 { + case inAppNotificationSettings +} + +struct ApplicationSpecificPreferencesKeys { + static let inAppNotificationSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.inAppNotificationSettings.rawValue) +} diff --git a/TelegramUI/RadialProgressNode.swift b/TelegramUI/RadialProgressNode.swift index 9083024d84..f4e9c7c68b 100644 --- a/TelegramUI/RadialProgressNode.swift +++ b/TelegramUI/RadialProgressNode.swift @@ -67,7 +67,7 @@ private class RadialProgressOverlayNode: ASDisplayNode { //CGContextSetLineCap(context, .Round) switch parameters.state { - case .None, .Remote, .Play, .Pause, .Icon: + case .None, .Remote, .Play, .Pause, .Icon, .Image: break case let .Fetching(progress): let startAngle = -CGFloat(M_PI_2) @@ -111,6 +111,7 @@ public enum RadialProgressState { case Play case Pause case Icon + case Image(UIImage) } public struct RadialProgressTheme { @@ -178,6 +179,12 @@ class RadialProgressNode: ASControlNode { default: self.setNeedsDisplay() } + case let .Image(lhsImage): + if case let .Image(rhsImage) = self.state, lhsImage === rhsImage { + break + } else { + self.setNeedsDisplay() + } } } } @@ -300,6 +307,8 @@ class RadialProgressNode: ASControlNode { context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) } context.translateBy(x: -(parameters.diameter - size.width) / 2.0, y: -(parameters.diameter - size.height) / 2.0) + case let .Image(image): + image.draw(at: CGPoint(x: floor((parameters.diameter - image.size.width) / 2.0), y: floor((parameters.diameter - image.size.height) / 2.0))) } } } diff --git a/TelegramUI/RadialTimeoutNode.swift b/TelegramUI/RadialTimeoutNode.swift new file mode 100644 index 0000000000..1dba21a79a --- /dev/null +++ b/TelegramUI/RadialTimeoutNode.swift @@ -0,0 +1,109 @@ +import Foundation +import AsyncDisplayKit +import Display + +private class RadialTimeoutNodeParameters: NSObject { + let backgroundColor: UIColor + let foregroundColor: UIColor + let value: CGFloat + + init(backgroundColor: UIColor, foregroundColor: UIColor, value: CGFloat) { + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor + self.value = value + + super.init() + } +} + +private final class RadialTimeoutNodeTimer: NSObject { + let action: () -> Void + init(_ action: @escaping () -> Void) { + self.action = action + + super.init() + } + + @objc func event() { + self.action() + } +} + +public final class RadialTimeoutNode: ASDisplayNode { + private let nodeBackgroundColor: UIColor + private let nodeForegroundColor: UIColor + + private var timeout: (Double, Double)? + + private var animationTimer: Timer? + + public init(backgroundColor: UIColor, foregroundColor: UIColor) { + self.nodeBackgroundColor = backgroundColor + self.nodeForegroundColor = foregroundColor + + super.init() + + self.isOpaque = false + } + + deinit { + self.animationTimer?.invalidate() + } + + public func setTimeout(beginTimestamp: Double, timeout: Double) { + if self.timeout?.0 != beginTimestamp || self.timeout?.1 != timeout { + self.animationTimer?.invalidate() + self.timeout = (beginTimestamp, timeout) + + let animationTimer = Timer(timeInterval: 1.0 / 60.0, target: RadialTimeoutNodeTimer({ [weak self] in + self?.setNeedsDisplay() + }), selector: #selector(RadialTimeoutNodeTimer.event), userInfo: nil, repeats: true) + self.animationTimer = animationTimer + RunLoop.main.add(animationTimer, forMode: RunLoopMode.commonModes) + } + } + + public override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { + var value: CGFloat = 0.0 + if let (beginTimestamp, timeout) = self.timeout { + let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 + value = CGFloat(max(0.0, min(1.0, (timestamp - beginTimestamp) / timeout))) + } + return RadialTimeoutNodeParameters(backgroundColor: self.nodeBackgroundColor, foregroundColor: self.nodeForegroundColor, value: value) + } + + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { + let context = UIGraphicsGetCurrentContext()! + + if !isRasterizing { + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fill(bounds) + } + + if let parameters = parameters as? RadialTimeoutNodeParameters { + context.setFillColor(parameters.backgroundColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: bounds.size.width, height: bounds.size.height))) + + context.setFillColor(parameters.foregroundColor.cgColor) + //context.fill(CGRect(origin: CGPoint(), size: CGSize(width: bounds.size.width, height: bounds.size.height * parameters.value))) + + let radius = (bounds.size.width - 4.0) * 0.5 + + let viewCenter = CGPoint(x: bounds.size.width * 0.5, y: bounds.size.height * 0.5) + var startAngle = -CGFloat.pi * 0.5 + + // update the end angle of the segment + let endAngle = startAngle + 2.0 * CGFloat.pi * parameters.value + + // move to the center of the pie chart + context.move(to: viewCenter) + + // add arc from the center for each segment (anticlockwise is specified for the arc, but as the view flips the context, it will produce a clockwise arc) + context.addArc(center: viewCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false) + + // fill segment + context.fillPath() + } + } +} diff --git a/TelegramUI/SecretChatKeyVisualization.h b/TelegramUI/SecretChatKeyVisualization.h new file mode 100644 index 0000000000..f92ae001f4 --- /dev/null +++ b/TelegramUI/SecretChatKeyVisualization.h @@ -0,0 +1,4 @@ +#import +#import + +UIImage *SecretChatKeyVisualization(NSData *data, NSData *additionalData, CGSize size); diff --git a/TelegramUI/SecretChatKeyVisualization.m b/TelegramUI/SecretChatKeyVisualization.m new file mode 100644 index 0000000000..ab1a95160a --- /dev/null +++ b/TelegramUI/SecretChatKeyVisualization.m @@ -0,0 +1,109 @@ +#import "SecretChatKeyVisualization.h" + +#define UIColorRGB(rgb) ([[UIColor alloc] initWithRed:(((rgb >> 16) & 0xff) / 255.0f) green:(((rgb >> 8) & 0xff) / 255.0f) blue:(((rgb) & 0xff) / 255.0f) alpha:1.0f]) + +static int32_t get_bits(uint8_t const *bytes, unsigned int bitOffset, unsigned int numBits) +{ + uint8_t const *data = bytes; + numBits = (unsigned int)pow(2, numBits) - 1; //this will only work up to 32 bits, of course + data += bitOffset / 8; + bitOffset %= 8; + return (*((int*)data) >> bitOffset) & numBits; +} + +UIImage *SecretChatKeyVisualization(NSData *data, NSData *additionalData, CGSize size) { + uint8_t bits[128]; + memset(bits, 0, 128); + + uint8_t additionalBits[256 * 8]; + memset(additionalBits, 0, 256 * 8); + + [data getBytes:bits length:MIN((NSUInteger)128, data.length)]; + [additionalData getBytes:additionalBits length:MIN((NSUInteger)256, additionalData.length)]; + + static CGColorRef colors[6]; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + static const int textColors[] = + { + 0xffffff, + 0xd5e6f3, + 0x2d5775, + 0x2f99c9 + }; + + for (int i = 0; i < 4; i++) + { + colors[i] = CGColorRetain(UIColorRGB(textColors[i]).CGColor); + } + }); + + UIGraphicsBeginImageContextWithOptions(size, true, 0.0f); + CGContextRef context = UIGraphicsGetCurrentContext(); + + CGContextSetFillColorWithColor(context, colors[0]); + CGContextFillRect(context, CGRectMake(0.0f, 0.0f, size.width, size.height)); + + if (additionalData == nil) { + int bitPointer = 0; + + CGFloat rectSize = size.width / 8.0f; + + for (int iy = 0; iy < 8; iy++) + { + for (int ix = 0; ix < 8; ix++) + { + int32_t byteValue = get_bits(bits, bitPointer, 2); + bitPointer += 2; + int colorIndex = ABS(byteValue) % 4; + + CGContextSetFillColorWithColor(context, colors[colorIndex]); + + CGRect rect = CGRectMake(ix * rectSize, iy * rectSize, rectSize, rectSize); + if (size.width > 200) { + rect.origin.x = ceil(rect.origin.x); + rect.origin.y = ceil(rect.origin.y); + rect.size.width = ceil(rect.size.width); + rect.size.height = ceil(rect.size.height); + } + CGContextFillRect(context, rect); + } + } + } else { + int bitPointer = 0; + + CGFloat rectSize = size.width / 12.0f; + + for (int iy = 0; iy < 12; iy++) + { + for (int ix = 0; ix < 12; ix++) + { + int32_t byteValue = 0; + if (bitPointer < 128) { + byteValue = get_bits(bits, bitPointer, 2); + } else { + byteValue = get_bits(additionalBits, bitPointer - 128, 2); + } + bitPointer += 2; + int colorIndex = ABS(byteValue) % 4; + + CGContextSetFillColorWithColor(context, colors[colorIndex]); + + CGRect rect = CGRectMake(ix * rectSize, iy * rectSize, rectSize, rectSize); + if (size.width > 200) { + rect.origin.x = ceil(rect.origin.x); + rect.origin.y = ceil(rect.origin.y); + rect.size.width = ceil(rect.size.width); + rect.size.height = ceil(rect.size.height); + } + CGContextFillRect(context, rect); + } + } + } + + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return image; +} diff --git a/TelegramUI/SecretMediaPreviewController.swift b/TelegramUI/SecretMediaPreviewController.swift new file mode 100644 index 0000000000..1aeeaf7d78 --- /dev/null +++ b/TelegramUI/SecretMediaPreviewController.swift @@ -0,0 +1,100 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +public final class SecretMediaPreviewController: ViewController { + private let account: Account + + private let _ready = Promise() + override public var ready: Promise { + return self._ready + } + private var didSetReady = false + + private let disposable = MetaDisposable() + private let markMessageAsConsumedDisposable = MetaDisposable() + + private var controllerNode: SecretMediaPreviewControllerNode { + return self.displayNode as! SecretMediaPreviewControllerNode + } + + private var messageView: MessageView? + private var currentNodeMessageId: MessageId? + + public init(account: Account, messageId: MessageId) { + self.account = account + + super.init() + + self.navigationBar.isHidden = true + self.statusBar.alpha = 0.0 + + self.disposable.set((account.postbox.messageView(messageId) |> deliverOnMainQueue).start(next: { [weak self] view in + if let strongSelf = self { + strongSelf.messageView = view + if strongSelf.isViewLoaded { + strongSelf.applyMessageView() + } + } + })) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable.dispose() + self.markMessageAsConsumedDisposable.dispose() + } + + public override func loadDisplayNode() { + self.displayNode = SecretMediaPreviewControllerNode() + self.displayNodeDidLoad() + + self.controllerNode.dismiss = { [weak self] in + self?.dismiss() + } + + if let messageView = self.messageView { + applyMessageView() + } + } + + private func applyMessageView() { + if let messageView = self.messageView, let message = messageView.message { + if self.currentNodeMessageId != message.id { + self.currentNodeMessageId = message.id + let item = galleryItemForEntry(account: account, entry: .MessageEntry(message, false, nil)) + let itemNode = item.node() + self.controllerNode.setItemNode(itemNode) + + let ready = (itemNode.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void()))) |> afterNext { [weak self] _ in + self?.didSetReady = true + } + self._ready.set(ready |> map { true }) + + self.markMessageAsConsumedDisposable.set(markMessageContentAsConsumedInteractively(postbox: self.account.postbox, network: self.account.network, messageId: message.id).start()) + } + } else { + if !self.didSetReady { + self._ready.set(.single(true)) + } + self.dismiss() + } + } + + public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.frame = CGRect(origin: CGPoint(), size: layout.size) + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: transition) + } + + public func dismiss() { + self.presentingViewController?.dismiss(animated: false, completion: nil) + } +} diff --git a/TelegramUI/SecretMediaPreviewControllerNode.swift b/TelegramUI/SecretMediaPreviewControllerNode.swift new file mode 100644 index 0000000000..ec38e17efe --- /dev/null +++ b/TelegramUI/SecretMediaPreviewControllerNode.swift @@ -0,0 +1,72 @@ +import Foundation +import AsyncDisplayKit +import Display + +class SecretMediaPreviewControllerNode: ASDisplayNode { + var containerLayout: (CGFloat, ContainerViewLayout)? + var backgroundNode: ASDisplayNode + + var dismiss: (() -> Void)? + + private var itemNode: GalleryItemNode? + private var itemNodeActivated = false + + override init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.backgroundColor = UIColor.black + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.addSubnode(self.backgroundNode) + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (navigationBarHeight, layout) + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: layout.size.height))) + if let itemNode = self.itemNode { + transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: layout.size.height))) + itemNode.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: transition) + if !self.itemNodeActivated { + self.itemNodeActivated = true + itemNode.centralityUpdated(isCentral: true) + } + } + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.dismiss?() + } + } + + func setItemNode(_ itemNode: GalleryItemNode?) { + if let itemNode = self.itemNode { + itemNode.removeFromSupernode() + self.itemNodeActivated = false + } + + self.itemNode = itemNode + + if let itemNode = self.itemNode { + self.addSubnode(itemNode) + + if let (_, layout) = self.containerLayout { + itemNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: layout.size.height)) + itemNode.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: .immediate) + if !self.itemNodeActivated { + self.itemNodeActivated = true + itemNode.centralityUpdated(isCentral: true) + } + } + } + } +} diff --git a/TelegramUI/SecuritySettings.swift b/TelegramUI/SecuritySettings.swift new file mode 100644 index 0000000000..7e433cdf36 --- /dev/null +++ b/TelegramUI/SecuritySettings.swift @@ -0,0 +1,12 @@ +import Foundation + +enum PasscodeSetting { + case none + case simple + case password +} + +enum AccountPassword { + case none + case password +} diff --git a/TelegramUI/SettingsControllerEntries.swift b/TelegramUI/SettingsControllerEntries.swift new file mode 100644 index 0000000000..c652c8f2e6 --- /dev/null +++ b/TelegramUI/SettingsControllerEntries.swift @@ -0,0 +1,366 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private struct SettingsItemArguments { + let account: Account + + let pushController: (ViewController) -> Void + let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void + let saveEditingState: () -> Void +} + +private enum SettingsSection: Int32 { + case info + case generalSettings + case accountSettings + case help + case logOut +} + +private enum SettingsEntry: ItemListNodeEntry { + case userInfo(Peer?, CachedPeerData?, ItemListAvatarAndNameInfoItemState) + case setProfilePhoto + + case notificationsAndSounds + case privacyAndSecurity + case dataAndStorage + case stickers + case phoneNumber(String) + case username(String) + case askAQuestion + case faq + case logOut + + var section: ItemListSectionId { + switch self { + case .userInfo, .setProfilePhoto: + return SettingsSection.info.rawValue + case .notificationsAndSounds, .privacyAndSecurity, .dataAndStorage, .stickers: + return SettingsSection.generalSettings.rawValue + case .phoneNumber, .username: + return SettingsSection.accountSettings.rawValue + case .askAQuestion, .faq: + return SettingsSection.help.rawValue + case .logOut: + return SettingsSection.logOut.rawValue + } + } + + var stableId: Int32 { + switch self { + case .userInfo: + return 0 + case .setProfilePhoto: + return 1 + case .notificationsAndSounds: + return 2 + case .privacyAndSecurity: + return 3 + case .dataAndStorage: + return 4 + case .stickers: + return 5 + case .phoneNumber: + return 6 + case .username: + return 7 + case .askAQuestion: + return 8 + case .faq: + return 9 + case .logOut: + return 10 + } + } + + static func ==(lhs: SettingsEntry, rhs: SettingsEntry) -> Bool { + switch lhs { + case let .userInfo(lhsPeer, lhsCachedData, lhsEditingState): + if case let .userInfo(rhsPeer, rhsCachedData, rhsEditingState) = rhs { + if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + } else if (lhsPeer != nil) != (rhsPeer != nil) { + return false + } + if let lhsCachedData = lhsCachedData, let rhsCachedData = rhsCachedData { + if !lhsCachedData.isEqual(to: rhsCachedData) { + return false + } + } else if (lhsCachedData != nil) != (rhsCachedData != nil) { + return false + } + if lhsEditingState != rhsEditingState { + return false + } + return true + } else { + return false + } + case .setProfilePhoto: + if case .setProfilePhoto = rhs { + return true + } else { + return false + } + case .notificationsAndSounds: + if case .notificationsAndSounds = rhs { + return true + } else { + return false + } + case .privacyAndSecurity: + if case .privacyAndSecurity = rhs { + return true + } else { + return false + } + case .dataAndStorage: + if case .dataAndStorage = rhs { + return true + } else { + return false + } + case .stickers: + if case .stickers = rhs { + return true + } else { + return false + } + case let .phoneNumber(number): + if case .phoneNumber(number) = rhs { + return true + } else { + return false + } + case let .username(address): + if case .username(address) = rhs { + return true + } else { + return false + } + case .askAQuestion: + if case .askAQuestion = rhs { + return true + } else { + return false + } + case .faq: + if case .faq = rhs { + return true + } else { + return false + } + case .logOut: + if case .logOut = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: SettingsEntry, rhs: SettingsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: SettingsItemArguments) -> ListViewItem { + switch self { + case let .userInfo(peer, cachedData, state): + return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, cachedData: cachedData, state: state, sectionId: ItemListSectionId(self.section), style: .blocks, editingNameUpdated: { editingName in + arguments.updateEditingName(editingName) + }) + case .setProfilePhoto: + return ItemListActionItem(title: "Set Profile Photo", kind: .generic, alignment: .natural, sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case .notificationsAndSounds: + return ItemListDisclosureItem(title: "Notifications ans Sounds", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { + arguments.pushController(notificationsAndSoundsController(account: arguments.account)) + }) + case .privacyAndSecurity: + return ItemListDisclosureItem(title: "Privacy and Security", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case .dataAndStorage: + return ItemListDisclosureItem(title: "Data and Storage", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case .stickers: + return ItemListDisclosureItem(title: "Stickers", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case let .phoneNumber(number): + return ItemListDisclosureItem(title: "Phone Number", label: number, sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case let .username(address): + return ItemListDisclosureItem(title: "Username", label: address, sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case .askAQuestion: + return ItemListDisclosureItem(title: "Ask a Question", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case .faq: + return ItemListDisclosureItem(title: "Telegram FAQ", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + case .logOut: + return ItemListActionItem(title: "Log Out", kind: .destructive, alignment: .center, sectionId: ItemListSectionId(self.section), style: .blocks, action: { + + }) + } + } +} + +private struct SettingsEditingState: Equatable { + let editingName: ItemListAvatarAndNameInfoItemName + + static func ==(lhs: SettingsEditingState, rhs: SettingsEditingState) -> Bool { + if lhs.editingName != rhs.editingName { + return false + } + + return true + } +} + +private struct SettingsState: Equatable { + let editingState: SettingsEditingState? + let updatingName: ItemListAvatarAndNameInfoItemName? + + func withUpdatedEditingState(_ editingState: SettingsEditingState?) -> SettingsState { + return SettingsState(editingState: editingState, updatingName: self.updatingName) + } + + func withUpdatedUpdatingName(_ updatingName: ItemListAvatarAndNameInfoItemName?) -> SettingsState { + return SettingsState(editingState: self.editingState, updatingName: updatingName) + } + + static func ==(lhs: SettingsState, rhs: SettingsState) -> Bool { + if lhs.editingState != rhs.editingState { + return false + } + if lhs.updatingName != rhs.updatingName { + return false + } + return true + } +} + +private func settingsEntries(state: SettingsState, view: PeerView) -> [SettingsEntry] { + var entries: [SettingsEntry] = [] + + 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(.setProfilePhoto) + + entries.append(.notificationsAndSounds) + entries.append(.privacyAndSecurity) + entries.append(.dataAndStorage) + entries.append(.stickers) + + if let phone = peer.phone { + entries.append(.phoneNumber(formatPhoneNumber(phone))) + } + entries.append(.username(peer.addressName == nil ? "" : ("@" + peer.addressName!))) + + entries.append(.askAQuestion) + entries.append(.faq) + + if let _ = state.editingState { + entries.append(.logOut) + } + } + + return entries +} + +public func settingsController(account: Account) -> ViewController { + let statePromise = ValuePromise(SettingsState(editingState: nil, updatingName: nil), ignoreRepeated: true) + let stateValue = Atomic(value: SettingsState(editingState: nil, updatingName: nil)) + let updateState: ((SettingsState) -> SettingsState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var pushControllerImpl: ((ViewController) -> Void)? + + let actionsDisposable = DisposableSet() + + let updatePeerNameDisposable = MetaDisposable() + actionsDisposable.add(updatePeerNameDisposable) + + let arguments = SettingsItemArguments(account: account, pushController: { controller in + pushControllerImpl?(controller) + }, updateEditingName: { editingName in + updateState { state in + if let _ = state.editingState { + return state.withUpdatedEditingState(SettingsEditingState(editingName: editingName)) + } else { + return state + } + } + }, saveEditingState: { + var updateName: ItemListAvatarAndNameInfoItemName? + updateState { state in + if let editingState = state.editingState { + updateName = editingState.editingName + return state.withUpdatedEditingState(nil).withUpdatedUpdatingName(editingState.editingName) + } else { + return state + } + } + if let updateName = updateName, case let .personName(firstName, lastName) = updateName { + updatePeerNameDisposable.set((updateAccountPeerName(account: account, firstName: firstName, lastName: lastName) |> afterDisposed { + Queue.mainQueue().async { + updateState { state in + return state.withUpdatedUpdatingName(nil) + } + } + }).start()) + } + }) + + let peerView = account.viewTracker.peerView(account.peerId) + + let signal = combineLatest(statePromise.get(), peerView) + |> map { state, view -> (ItemListControllerState, (ItemListNodeState, SettingsEntry.ItemGenerationArguments)) in + let peer = peerViewMainPeer(view) + let rightNavigationButton: ItemListNavigationButton + if let _ = state.editingState { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + arguments.saveEditingState() + }) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .bold, enabled: true, action: { + if let peer = peer as? TelegramUser { + updateState { state in + return state.withUpdatedEditingState(SettingsEditingState(editingName: ItemListAvatarAndNameInfoItemName(peer.indexName))) + } + } + }) + } + + let controllerState = ItemListControllerState(title: "Settings", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let listState = ItemListNodeState(entries: settingsEntries(state: state, view: view), style: .blocks) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.tabBarItem.title = "Settings" + controller.tabBarItem.image = UIImage(bundleImageName: "Chat List/Tabs/IconSettings")?.precomposed() + controller.tabBarItem.selectedImage = UIImage(bundleImageName: "Chat List/Tabs/IconSettingsSelected")?.precomposed() + pushControllerImpl = { [weak controller] value in + (controller?.navigationController as? NavigationController)?.pushViewController(value) + } + return controller +} diff --git a/TelegramUI/StoredMessageFromSearchPeer.swift b/TelegramUI/StoredMessageFromSearchPeer.swift index 25057f9b30..78524f72c2 100644 --- a/TelegramUI/StoredMessageFromSearchPeer.swift +++ b/TelegramUI/StoredMessageFromSearchPeer.swift @@ -6,7 +6,7 @@ import SwiftSignalKit func storedMessageFromSearchPeer(account: Account, peer: Peer) -> Signal { return account.postbox.modify { modifier -> Void in if modifier.getPeer(peer.id) == nil { - modifier.updatePeers([peer], update: { previousPeer, updatedPeer in + updatePeers(modifier: modifier, peers: [peer], update: { previousPeer, updatedPeer in return updatedPeer }) } diff --git a/TelegramUI/StringWithAppliedEntities.swift b/TelegramUI/StringWithAppliedEntities.swift index 6abc3e6fee..1872f5e49a 100644 --- a/TelegramUI/StringWithAppliedEntities.swift +++ b/TelegramUI/StringWithAppliedEntities.swift @@ -11,7 +11,15 @@ func stringWithAppliedEntities(_ text: String, entities: [MessageTextEntity], ba continue } let entity = entities[i] - let range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) + var range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) + if nsString == nil { + nsString = text as NSString + } + if range.location + range.length > nsString!.length { + let upperBound = nsString!.length + range.location = max(0, upperBound - range.length) + range.length = upperBound - range.location + } switch entity.type { case .Url: string.addAttribute(NSForegroundColorAttributeName, value: UIColor(0x004bad), range: range) diff --git a/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift b/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift index ffef2c7833..a28a3d1610 100644 --- a/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift +++ b/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift @@ -17,12 +17,23 @@ private class TapLongTapOrDoubleTapGestureRecognizerTimerTarget: NSObject { @objc func tapEvent() { self.target?.tapEvent() } + + @objc func holdEvent() { + self.target?.holdEvent() + } } enum TapLongTapOrDoubleTapGesture { case tap case doubleTap case longTap + case hold +} + +enum TapLongTapOrDoubleTapGestureRecognizerAction { + case waitForDoubleTap + case waitForSingleTap + case waitForHold } final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate { @@ -32,7 +43,7 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu private var timer: Foundation.Timer? private(set) var lastRecognizedGestureAndLocation: (TapLongTapOrDoubleTapGesture, CGPoint)? - var doNotWaitForDoubleTapAtPoint: ((CGPoint) -> Bool)? + var tapActionAtPoint: ((CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction)? override init(target: Any?, action: Selector?) { super.init(target: target, action: action) @@ -78,6 +89,17 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu self.state = .ended } + fileprivate func holdEvent() { + self.timer?.invalidate() + self.timer = nil + if let (location, _) = self.touchLocationAndTimestamp { + self.lastRecognizedGestureAndLocation = (.hold, location) + } else { + self.lastRecognizedGestureAndLocation = nil + } + self.state = .began + } + override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) @@ -94,12 +116,26 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu self.lastRecognizedGestureAndLocation = (.doubleTap, self.location(in: self.view)) self.state = .ended } else { - self.touchLocationAndTimestamp = (touch.location(in: self.view), CACurrentMediaTime()) + let touchLocationAndTimestamp = (touch.location(in: self.view), CACurrentMediaTime()) + self.touchLocationAndTimestamp = touchLocationAndTimestamp + + var tapAction: TapLongTapOrDoubleTapGestureRecognizerAction = .waitForDoubleTap + if let tapActionAtPoint = self.tapActionAtPoint { + tapAction = tapActionAtPoint(touchLocationAndTimestamp.0) + } - self.timer?.invalidate() - 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) + switch tapAction { + case .waitForSingleTap, .waitForDoubleTap: + self.timer?.invalidate() + 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) + self.timer = timer + RunLoop.main.add(timer, forMode: RunLoopMode.commonModes) + } } } } @@ -107,6 +143,10 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) + if let (gesture, _) = self.lastRecognizedGestureAndLocation, case .hold = gesture { + return + } + if let touch = touches.first, let (touchLocation, _) = self.touchLocationAndTimestamp { let location = touch.location(in: self.view) let distance = CGPoint(x: location.x - touchLocation.x, y: location.y - touchLocation.y) @@ -121,16 +161,38 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu self.timer?.invalidate() + if let (gesture, location) = self.lastRecognizedGestureAndLocation, case .hold = gesture { + self.lastRecognizedGestureAndLocation = (.hold, location) + self.state = .ended + return + } + if self.tapCount == 1 { - if let doNotWaitForDoubleTapAtPoint = self.doNotWaitForDoubleTapAtPoint, let (touchLocation, _) = self.touchLocationAndTimestamp, doNotWaitForDoubleTapAtPoint(touchLocation) { - self.lastRecognizedGestureAndLocation = (.tap, touchLocation) - self.state = .ended - } else { - self.state = .began - 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) + var tapAction: TapLongTapOrDoubleTapGestureRecognizerAction = .waitForDoubleTap + if let tapActionAtPoint = self.tapActionAtPoint, let (touchLocation, _) = self.touchLocationAndTimestamp { + tapAction = tapActionAtPoint(touchLocation) + } + + switch tapAction { + case .waitForSingleTap: + if let (touchLocation, _) = self.touchLocationAndTimestamp { + self.lastRecognizedGestureAndLocation = (.tap, touchLocation) + } + self.state = .ended + case .waitForDoubleTap: + self.state = .began + 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 } } } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + + self.state = .cancelled + } } diff --git a/TelegramUI/UserInfoEntries.swift b/TelegramUI/UserInfoEntries.swift index 3a4343f7fa..50a245752a 100644 --- a/TelegramUI/UserInfoEntries.swift +++ b/TelegramUI/UserInfoEntries.swift @@ -4,25 +4,11 @@ import TelegramCore import SwiftSignalKit import Display -private enum UserInfoSection: UInt32, PeerInfoSection { +private enum UserInfoSection: ItemListSectionId { case info case actions case sharedMediaAndNotifications case block - - func isEqual(to: PeerInfoSection) -> Bool { - guard let section = to as? UserInfoSection else { - return false - } - return section == self - } - - func isOrderedBefore(_ section: PeerInfoSection) -> Bool { - guard let section = section as? UserInfoSection else { - return false - } - return self.rawValue < section.rawValue - } } enum DestructiveUserInfoAction { @@ -31,7 +17,7 @@ enum DestructiveUserInfoAction { } enum UserInfoEntry: PeerInfoEntry { - case info(peer: Peer?, cachedData: CachedPeerData?, editingState: PeerInfoAvatarAndNameItemEditingState?) + case info(peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState) case about(text: String) case phoneNumber(index: Int, value: PhoneNumberWithLabel) case userName(value: String) @@ -41,18 +27,19 @@ enum UserInfoEntry: PeerInfoEntry { case sharedMedia case notifications(settings: PeerNotificationSettings?) case notificationSound(settings: PeerNotificationSettings?) + case secretEncryptionKey(SecretChatKeyFingerprint) case block(action: DestructiveUserInfoAction) - var section: PeerInfoSection { + var section: ItemListSectionId { switch self { case .info, .about, .phoneNumber, .userName: - return UserInfoSection.info + return UserInfoSection.info.rawValue case .sendMessage, .shareContact, .startSecretChat: - return UserInfoSection.actions - case .sharedMedia, .notifications, .notificationSound: - return UserInfoSection.sharedMediaAndNotifications + return UserInfoSection.actions.rawValue + case .sharedMedia, .notifications, .notificationSound, .secretEncryptionKey: + return UserInfoSection.sharedMediaAndNotifications.rawValue case .block: - return UserInfoSection.block + return UserInfoSection.block.rawValue } } @@ -66,9 +53,9 @@ enum UserInfoEntry: PeerInfoEntry { } switch self { - case let .info(lhsPeer, lhsCachedData, lhsEditingState): + case let .info(lhsPeer, lhsCachedData, lhsState): switch entry { - case let .info(rhsPeer, rhsCachedData, rhsEditingState): + case let .info(rhsPeer, rhsCachedData, rhsState): if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { if !lhsPeer.isEqual(rhsPeer) { return false @@ -83,7 +70,7 @@ enum UserInfoEntry: PeerInfoEntry { } else if (lhsCachedData != nil) != (rhsCachedData != nil) { return false } - if lhsEditingState != rhsEditingState { + if lhsState != rhsState { return false } return true @@ -163,6 +150,12 @@ enum UserInfoEntry: PeerInfoEntry { default: return false } + case let .secretEncryptionKey(fingerprint): + if case .secretEncryptionKey(fingerprint) = entry { + return true + } else { + return false + } case let .block(action): switch entry { case .block(action): @@ -195,8 +188,10 @@ enum UserInfoEntry: PeerInfoEntry { return 1005 case .notificationSound: return 1006 - case .block: + case .secretEncryptionKey: return 1007 + case .block: + return 1008 } } @@ -210,28 +205,30 @@ enum UserInfoEntry: PeerInfoEntry { func item(account: Account, interaction: PeerInfoControllerInteraction) -> ListViewItem { switch self { - case let .info(peer, cachedData, editingState): - return PeerInfoAvatarAndNameItem(account: account, peer: peer, cachedData: cachedData, editingState: editingState, sectionId: self.section.rawValue, style: .plain) + case let .info(peer, cachedData, state): + return ItemListAvatarAndNameInfoItem(account: account, peer: peer, cachedData: cachedData, state: state, sectionId: self.section, style: .plain, editingNameUpdated: { editingName in + + }) case let .about(text): - return PeerInfoTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: self.section.rawValue) + return ItemListTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: self.section) case let .phoneNumber(_, value): - return PeerInfoTextWithLabelItem(label: value.label, text: formatPhoneNumber(value.number), multiline: false, sectionId: self.section.rawValue) + return ItemListTextWithLabelItem(label: value.label, text: formatPhoneNumber(value.number), multiline: false, sectionId: self.section) case let .userName(value): - return PeerInfoTextWithLabelItem(label: "username", text: "@\(value)", multiline: false, sectionId: self.section.rawValue) + return ItemListTextWithLabelItem(label: "username", text: "@\(value)", multiline: false, sectionId: self.section) case .sendMessage: - return PeerInfoActionItem(title: "Send Message", kind: .generic, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListActionItem(title: "Send Message", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { }) case .shareContact: - return PeerInfoActionItem(title: "Share Contact", kind: .generic, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListActionItem(title: "Share Contact", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { }) case .startSecretChat: - return PeerInfoActionItem(title: "Start Secret Chat", kind: .generic, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListActionItem(title: "Start Secret Chat", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { }) case .sharedMedia: - return PeerInfoDisclosureItem(title: "Shared Media", label: "", sectionId: self.section.rawValue, style: .plain, action: { + return ItemListDisclosureItem(title: "Shared Media", label: "", sectionId: self.section, style: .plain, action: { interaction.openSharedMedia() }) case let .notifications(settings): @@ -241,13 +238,16 @@ enum UserInfoEntry: PeerInfoEntry { } else { label = "Enabled" } - return PeerInfoDisclosureItem(title: "Notifications", label: label, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListDisclosureItem(title: "Notifications", label: label, sectionId: self.section, style: .plain, action: { interaction.changeNotificationMuteSettings() }) case let .notificationSound(settings): let label: String label = "Default" - return PeerInfoDisclosureItem(title: "Sound", label: label, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListDisclosureItem(title: "Sound", label: label, sectionId: self.section, style: .plain, action: { + }) + case let .secretEncryptionKey(fingerprint): + return ItemListDisclosureItem(title: "Encryption Key", label: "", sectionId: self.section, style: .plain, action: { }) case let .block(action): let title: String @@ -257,17 +257,20 @@ enum UserInfoEntry: PeerInfoEntry { case .removeContact: title = "Remove Contact" } - return PeerInfoActionItem(title: title, kind: .destructive, alignment: .natural, sectionId: self.section.rawValue, style: .plain, action: { + return ItemListActionItem(title: title, kind: .destructive, alignment: .natural, sectionId: self.section, style: .plain, action: { }) } } } -final class UserInfoEditingState: Equatable { - let infoState = PeerInfoAvatarAndNameItemEditingState() +struct UserInfoEditingState: Equatable { + let editingName: ItemListAvatarAndNameInfoItemName static func ==(lhs: UserInfoEditingState, rhs: UserInfoEditingState) -> Bool { + if lhs.editingName != rhs.editingName { + return false + } return true } } @@ -293,20 +296,25 @@ private final class UserInfoState: PeerInfoState { } func userInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { + guard let peer = view.peers[view.peerId], let user = peerViewMainPeer(view) as? TelegramUser else { + return PeerInfoEntries(entries: [], leftNavigationButton: nil, rightNavigationButton: nil) + } + var entries: [PeerInfoEntry] = [] - var infoEditingState: PeerInfoAvatarAndNameItemEditingState? + var editingName: ItemListAvatarAndNameInfoItemName? + var updatingName: ItemListAvatarAndNameInfoItemName? var isEditing = false if let state = state as? UserInfoState, let editingState = state.editingState { isEditing = true if view.peerIsContact { - infoEditingState = editingState.infoState + editingName = editingState.editingName } } - entries.append(UserInfoEntry.info(peer: view.peers[view.peerId], cachedData: view.cachedData, editingState: infoEditingState)) + entries.append(UserInfoEntry.info(peer: user, cachedData: view.cachedData, state: ItemListAvatarAndNameInfoItemState(editingName: editingName, updatingName: updatingName))) if let cachedUserData = view.cachedData as? CachedUserData { if let about = cachedUserData.about, !about.isEmpty { entries.append(UserInfoEntry.about(text: about)) @@ -314,33 +322,41 @@ func userInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { } var editable = true + if peer is TelegramSecretChat { + editable = false + } - if let user = view.peers[view.peerId] as? TelegramUser { - if let phoneNumber = user.phone, !phoneNumber.isEmpty { - entries.append(UserInfoEntry.phoneNumber(index: 0, value: PhoneNumberWithLabel(label: "home", number: phoneNumber))) + if let phoneNumber = user.phone, !phoneNumber.isEmpty { + entries.append(UserInfoEntry.phoneNumber(index: 0, value: PhoneNumberWithLabel(label: "home", number: phoneNumber))) + } + + if !isEditing { + if let username = user.username, !username.isEmpty { + entries.append(UserInfoEntry.userName(value: username)) } - if !isEditing { - if let username = user.username, !username.isEmpty { - entries.append(UserInfoEntry.userName(value: username)) - } + if !(peer is TelegramSecretChat) { entries.append(UserInfoEntry.sendMessage) if view.peerIsContact { entries.append(UserInfoEntry.shareContact) } entries.append(UserInfoEntry.startSecretChat) - entries.append(UserInfoEntry.sharedMedia) } - entries.append(UserInfoEntry.notifications(settings: view.notificationSettings)) - - if isEditing { - entries.append(UserInfoEntry.notificationSound(settings: view.notificationSettings)) - if view.peerIsContact { - entries.append(UserInfoEntry.block(action: .removeContact)) - } - } else { - entries.append(UserInfoEntry.block(action: .block)) + entries.append(UserInfoEntry.sharedMedia) + } + entries.append(UserInfoEntry.notifications(settings: view.notificationSettings)) + + if let peer = peer as? TelegramSecretChat { + entries.append(UserInfoEntry.secretEncryptionKey(SecretChatKeyFingerprint(k0: 0, k1: 0, k2: 0, k3: 0))) + } + + if isEditing { + entries.append(UserInfoEntry.notificationSound(settings: view.notificationSettings)) + if view.peerIsContact { + entries.append(UserInfoEntry.block(action: .removeContact)) } + } else { + entries.append(UserInfoEntry.block(action: .block)) } var leftNavigationButton: PeerInfoNavigationButton? @@ -366,11 +382,17 @@ func userInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { } }) } else { + let infoEditingName: ItemListAvatarAndNameInfoItemName + if let peer = peerViewMainPeer(view) { + infoEditingName = ItemListAvatarAndNameInfoItemName(peer.indexName) + } else { + infoEditingName = .personName(firstName: "", lastName: "") + } rightNavigationButton = PeerInfoNavigationButton(title: "Edit", action: { state in if state == nil { - return UserInfoState(editingState: UserInfoEditingState()) + return UserInfoState(editingState: UserInfoEditingState(editingName: infoEditingName)) } else if let state = state as? UserInfoState { - return state.updateEditingState(UserInfoEditingState()) + return state.updateEditingState(UserInfoEditingState(editingName: infoEditingName)) } else { return state }