diff --git a/Podfile b/Podfile index bca1c9dc7..10ffca28a 100644 --- a/Podfile +++ b/Podfile @@ -74,17 +74,6 @@ target 'SessionMessagingKit' do pod 'YapDatabase/SQLCipher', :git => 'https://github.com/loki-project/session-ios-yap-database.git', branch: 'signal-release', :inhibit_warnings => true end -target 'SessionProtocolKit' do - pod 'CocoaLumberjack', :inhibit_warnings => true - pod 'CryptoSwift', :inhibit_warnings => true - pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git', :inhibit_warnings => true - pod 'GRKOpenSSLFramework', :inhibit_warnings => true - pod 'HKDFKit', :inhibit_warnings => true - pod 'PromiseKit', :inhibit_warnings => true - pod 'SignalCoreKit', git: 'https://github.com/signalapp/SignalCoreKit.git', :inhibit_warnings => true - pod 'SwiftProtobuf', '~> 1.5.0', :inhibit_warnings => true -end - target 'SessionSnodeKit' do pod 'CryptoSwift', :inhibit_warnings => true pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git', :inhibit_warnings => true diff --git a/Podfile.lock b/Podfile.lock index 2e3981798..3fc70a96b 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -124,7 +124,6 @@ PODS: DEPENDENCIES: - AFNetworking - - CocoaLumberjack - CryptoSwift - Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) - FeedKit @@ -217,6 +216,6 @@ SPEC CHECKSUMS: YYImage: 6db68da66f20d9f169ceb94dfb9947c3867b9665 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: bb4f6cffd6e7c08814b945e1787d01d639036b1e +PODFILE CHECKSUM: 2fca3f32c171e1324c9e3809b96a32d4a929d05c -COCOAPODS: 1.10.1 +COCOAPODS: 1.10.0.rc.1 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 3c90d9ec1..f908d6dee 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -12,14 +12,10 @@ 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; }; 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87E204DAC8C007AEB0F /* PrivacySettingsTableViewController.m */; }; 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC883204DAC8C007AEB0F /* OWSSoundSettingsViewController.m */; }; - 340FC8B0204DAC8D007AEB0F /* AddToBlockListViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC886204DAC8C007AEB0F /* AddToBlockListViewController.m */; }; 340FC8B4204DAC8D007AEB0F /* OWSBackupSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC88E204DAC8C007AEB0F /* OWSBackupSettingsViewController.m */; }; - 340FC8B5204DAC8D007AEB0F /* AboutTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC893204DAC8C007AEB0F /* AboutTableViewController.m */; }; 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */; }; 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */; }; - 34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34129B8521EF8779005457A8 /* LinkPreviewView.swift */; }; 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 341341EE2187467900192D59 /* ConversationViewModel.m */; }; - 34277A5E20751BDC006049F2 /* OWSQuotedMessageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */; }; 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */; }; 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430FE171F7751D4000EC51B /* GiphyAPI.swift */; }; 34330A5A1E7875FB00DF2FB9 /* fontawesome-webfont.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 34330A591E7875FB00DF2FB9 /* fontawesome-webfont.ttf */; }; @@ -27,7 +23,6 @@ 34330A5E1E787BD800DF2FB9 /* ElegantIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 34330A5D1E787BD800DF2FB9 /* ElegantIcons.ttf */; }; 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34330AA21E79686200DF2FB9 /* OWSProgressView.m */; }; 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34386A53207D271C009F5D9C /* NeverClearView.swift */; }; - 343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 343A65961FC4CFE6000477A1 /* ConversationScrollButton.m */; }; 3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3441FD9E21A3604F00BB9542 /* BackupRestoreViewController.swift */; }; 34480B361FD0929200BC14EF /* ShareAppExtensionContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34480B351FD0929200BC14EF /* ShareAppExtensionContext.m */; }; 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */ = {isa = PBXBuildFile; fileRef = 344825C5211390C800DB4BD8 /* OWSOrphanDataCleaner.m */; }; @@ -42,9 +37,7 @@ 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; }; 347850551FD749C0007B8332 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; }; 347850571FD86544007B8332 /* SAEFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347850561FD86544007B8332 /* SAEFailedViewController.swift */; }; - 348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 348570A620F67574004FF32B /* OWSMessageHeaderView.m */; }; - 3488F9362191CC4000E524CC /* ConversationMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3488F9352191CC4000E524CC /* ConversationMediaView.swift */; }; - 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496744C2076768700080B5F /* OWSMessageBubbleView.m */; }; + 3488F9362191CC4000E524CC /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3488F9352191CC4000E524CC /* MediaView.swift */; }; 3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496744E2076ACCE00080B5F /* LongTextViewController.swift */; }; 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34969559219B605E00DCFE74 /* ImagePickerController.swift */; }; 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */; }; @@ -58,13 +51,10 @@ 3496957321A301A100DCFE74 /* OWSBackupJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956A21A301A100DCFE74 /* OWSBackupJob.m */; }; 3496957421A301A100DCFE74 /* OWSBackupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496956B21A301A100DCFE74 /* OWSBackupAPI.swift */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; - 34A8B3512190A40E00218A25 /* MediaAlbumCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumCellView.swift */; }; + 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */; }; - 34AC0A23211C829F00997B47 /* OWSLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = 34AC0A21211C829E00997B47 /* OWSLabel.m */; }; 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B0796B1FCF46B000E248C2 /* MainAppContext.m */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; - 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; - 34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */; }; 34B6A90B218BA1D1007C4606 /* typing-animation.gif in Resources */ = {isa = PBXBuildFile; fileRef = 34B6A90A218BA1D0007C4606 /* typing-animation.gif */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */; }; @@ -75,28 +65,12 @@ 34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */ = {isa = PBXBuildFile; fileRef = 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */; }; 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */; }; 34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */; }; - 34D1F0831F8678AA0066283D /* ConversationInputTextView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0681F8678AA0066283D /* ConversationInputTextView.m */; }; - 34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F06A1F8678AA0066283D /* ConversationInputToolbar.m */; }; - 34D1F0861F8678AA0066283D /* ConversationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F06E1F8678AA0066283D /* ConversationViewController.m */; }; 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0701F8678AA0066283D /* ConversationViewItem.m */; }; - 34D1F0881F8678AA0066283D /* ConversationViewLayout.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */; }; - 34D1F0A91F867BFC0066283D /* ConversationViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0971F867BFC0066283D /* ConversationViewCell.m */; }; - 34D1F0AE1F867BFC0066283D /* OWSMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0A21F867BFC0066283D /* OWSMessageCell.m */; }; - 34D1F0B01F867BFC0066283D /* OWSSystemMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */; }; - 34D1F0B41F86D31D0066283D /* ConversationCollectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0B31F86D31D0066283D /* ConversationCollectionView.m */; }; - 34D1F0B71F87F8850066283D /* OWSGenericAttachmentView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0B61F87F8850066283D /* OWSGenericAttachmentView.m */; }; - 34D1F0BD1F8D108C0066283D /* AttachmentUploadView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0BC1F8D108C0066283D /* AttachmentUploadView.m */; }; 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0BF1F8EC1760066283D /* MessageRecipientStatusUtils.swift */; }; 34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */; }; 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */; }; - 34D920E720E179C200D51158 /* OWSMessageFooterView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D920E620E179C200D51158 /* OWSMessageFooterView.m */; }; 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */; }; - 34DBF003206BD5A500025978 /* OWSMessageTextView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34DBEFFF206BD5A400025978 /* OWSMessageTextView.m */; }; - 34DBF004206BD5A500025978 /* OWSBubbleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34DBF001206BD5A500025978 /* OWSBubbleView.m */; }; - 34DBF007206C3CB200025978 /* OWSBubbleShapeView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34DBF006206C3CB200025978 /* OWSBubbleShapeView.m */; }; 34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */; }; - 34EA69402194933900702471 /* MediaDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EA693F2194933900702471 /* MediaDownloadView.swift */; }; - 34EA69422194DE8000702471 /* MediaUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EA69412194DE7F00702471 /* MediaUploadView.swift */; }; 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34F308A11ECB469700BB7697 /* OWSBezierPathView.m */; }; 3681EBBAC430992520DBD9AC /* Pods_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 200605FD180CB8B89F566B41 /* Pods_SessionShareExtension.framework */; }; 4503F1BE20470A5B00CEE724 /* classic-quiet.aifc in Resources */ = {isa = PBXBuildFile; fileRef = 4503F1BB20470A5B00CEE724 /* classic-quiet.aifc */; }; @@ -116,7 +90,6 @@ 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A84032059C787008B8C75 /* MediaTileViewController.swift */; }; 455A16DD1F1FEA0000F86704 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DB1F1FEA0000F86704 /* Metal.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 455A16DE1F1FEA0000F86704 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DC1F1FEA0000F86704 /* MetalKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; - 457F671B20746193000EABCD /* QuotedReplyPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457F671A20746193000EABCD /* QuotedReplyPreview.swift */; }; 45847E871E4283C30080EAB3 /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 45847E861E4283C30080EAB3 /* Intents.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 45A2F005204473A3002E978A /* NewMessage.aifc in Resources */ = {isa = PBXBuildFile; fileRef = 45A2F004204473A3002E978A /* NewMessage.aifc */; }; 45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A6DAD51EBBF85500893231 /* ReminderView.swift */; }; @@ -153,8 +126,6 @@ 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; }; 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */; }; 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */; }; - 45F32C242057297A00A300D5 /* MessageDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA1C261F7156F300E51C51 /* MessageDetailViewController.swift */; }; - 4C04392A220A9EC800BAEA63 /* VoiceNoteLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C043929220A9EC800BAEA63 /* VoiceNoteLock.swift */; }; 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */; }; 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */; }; 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */; }; @@ -168,8 +139,6 @@ 4C9CA25D217E676900607C63 /* ZXingObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C9CA25C217E676900607C63 /* ZXingObjC.framework */; }; 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F4B219CCC630038ABDE /* CaptionView.swift */; }; 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */; }; - 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */; }; - 4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB5F26820F7D060004D1B42 /* MessageActions.swift */; }; 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */; }; 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC613352227A00400E21A3A /* ConversationSearch.swift */; }; 4CFE6B6C21F92BA700006701 /* LegacyNotificationsAdaptee.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFE6B6B21F92BA700006701 /* LegacyNotificationsAdaptee.swift */; }; @@ -189,7 +158,6 @@ A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; A1C32D5017A06538000A904E /* AddressBookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4F17A06537000A904E /* AddressBookUI.framework */; }; A1C32D5117A06544000A904E /* AddressBook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4D17A0652C000A904E /* AddressBook.framework */; }; - A33A4BA9D050805FE156E3ED /* Pods_SessionProtocolKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2183DCA28E0620BC73FCC554 /* Pods_SessionProtocolKit.framework */; }; A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A5509EC91A69AB8B00ABA4BC /* Main.storyboard */; }; AD83FF3F1A73426500B5C81A /* audio_pause_button_blue.png in Resources */ = {isa = PBXBuildFile; fileRef = AD83FF381A73426500B5C81A /* audio_pause_button_blue.png */; }; AD83FF401A73426500B5C81A /* audio_pause_button_blue@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AD83FF391A73426500B5C81A /* audio_pause_button_blue@2x.png */; }; @@ -216,22 +184,40 @@ B6B226971BE4B7D200860F4D /* ContactsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6B226961BE4B7D200860F4D /* ContactsUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; B6F509971AA53F760068F56A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; }; B6FE7EB71ADD62FA00A6D22F /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6FE7EB61ADD62FA00A6D22F /* PushKit.framework */; }; + B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */; }; + B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */; }; B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80A579E23DFF1F300876683 /* NewClosedGroupVC.swift */; }; + B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494525D4D6FF009C0F2A /* URLModal.swift */; }; + B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494E25D4E163009C0F2A /* BodyTextView.swift */; }; + B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149B725D60393009C0F2A /* BlockedModal.swift */; }; + B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149C025D605C6009C0F2A /* InfoBanner.swift */; }; + B8214A2B25D63EB9009C0F2A /* MessagesTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */; }; + B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; + B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; }; + B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3C25C7B34D00488AB4 /* InputTextView.swift */; }; B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40872399EB0E00A248E7 /* LandingVC.swift */; }; B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B40892399EC0600A248E7 /* FakeChatView.swift */; }; B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B408B239A068800A248E7 /* RegisterVC.swift */; }; B82B408E239DC00D00A248E7 /* DisplayNameVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B408D239DC00D00A248E7 /* DisplayNameVC.swift */; }; B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B408F239DD75000A248E7 /* RestoreVC.swift */; }; - B82B4094239DF15900A248E7 /* ConversationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82B4093239DF15900A248E7 /* ConversationTitleView.swift */; }; + B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835246D25C38ABF0089A44F /* ConversationVC.swift */; }; + B835247925C38D880089A44F /* MessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835247825C38D880089A44F /* MessageCell.swift */; }; + B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835249A25C3AB650089A44F /* VisibleMessageCell.swift */; }; + B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */; }; B83786802586D296003CE78E /* KeyPairMigrationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B837867F2586D296003CE78E /* KeyPairMigrationSheet.swift */; }; B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */; }; B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; + B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; + B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */; }; B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357C223A1BD1200AAF6CD /* SeedVC.swift */; }; - B8544E3323D50E4900299F14 /* AppearanceUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8544E3223D50E4900299F14 /* AppearanceUtilities.swift */; }; + B8544E3323D50E4900299F14 /* SNAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8544E3223D50E4900299F14 /* SNAppearance.swift */; }; B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8566C62256F55930045A0B9 /* OWSLinkPreview+Conversion.swift */; }; B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2D1255B6DAF007E1867 /* OWSUserProfile.m */; }; B8566C7D256F62030045A0B9 /* OWSUserProfile.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */; }; + B8569AD325CBA13D00DBA3DB /* MediaTextOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AD225CBA13D00DBA3DB /* MediaTextOverlayView.swift */; }; + B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AE225CBB19A00DBA3DB /* DocumentView.swift */; }; B85A68B12587141A008CC492 /* Storage+Resetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85A68B02587141A008CC492 /* Storage+Resetting.swift */; }; B866CE112581C1A900535CC4 /* Sodium+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Conversion.swift */; }; B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; @@ -266,13 +252,14 @@ B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; B88847BC23E10BC6009836D2 /* GroupMembersVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88847BB23E10BC6009836D2 /* GroupMembersVC.swift */; }; + B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; + B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; B8A14D702589CE9000E70D57 /* KeyPairMigrationSuccessSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A14D6F2589CE9000E70D57 /* KeyPairMigrationSuccessSheet.swift */; }; B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; B8AE760B25ABFB5A001A84D2 /* GeneralUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = B8AE760A25ABFB5A001A84D2 /* GeneralUtilities.m */; }; B8AE761425ABFBB9001A84D2 /* GeneralUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = B8AE760925ABFB00001A84D2 /* GeneralUtilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B8B26C8F234D629C004ED98C /* MentionCandidateSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B26C8E234D629C004ED98C /* MentionCandidateSelectionView.swift */; }; B8B32021258B1A650020074B /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32020258B1A650020074B /* Contact.swift */; }; B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32032258B235D0020074B /* Storage+Contacts.swift */; }; B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32044258C117C0020074B /* ContactsMigration.swift */; }; @@ -284,24 +271,18 @@ B8C2B2C82563685C00551B4D /* CircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C2B2C72563685C00551B4D /* CircleView.swift */; }; B8C2B332256376F000551B4D /* ThreadUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B8C2B331256376F000551B4D /* ThreadUtil.m */; }; B8C2B3442563782400551B4D /* ThreadUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = B8C2B33B2563770800551B4D /* ThreadUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B8CA010125A293260091AF73 /* ClosedGroupSenderKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CA010025A293260091AF73 /* ClosedGroupSenderKey.swift */; }; - B8CA010B25A293530091AF73 /* ClosedGroupRatchet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CA010A25A293530091AF73 /* ClosedGroupRatchet.swift */; }; - B8CA011525A293800091AF73 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CA011425A293800091AF73 /* Configuration.swift */; }; - B8CA011F25A2939F0091AF73 /* SharedSenderKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CA011E25A2939F0091AF73 /* SharedSenderKeys.swift */; }; - B8CA014125A293EE0091AF73 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CA014025A293EE0091AF73 /* Storage.swift */; }; B8CADAE925AFADF400AAFA15 /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFCB25AE92150089E6DD /* OpenGroupManager.swift */; }; B8CCF6352396005F0091D419 /* SpaceMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B8CCF6342396005F0091D419 /* SpaceMono-Regular.ttf */; }; B8CCF63723961D6D0091D419 /* NewPrivateChatVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CCF63623961D6D0091D419 /* NewPrivateChatVC.swift */; }; - B8CCF63F23975CFB0091D419 /* JoinPublicChatVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CCF63E23975CFB0091D419 /* JoinPublicChatVC.swift */; }; + B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CCF63E23975CFB0091D419 /* JoinOpenGroupVC.swift */; }; B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8CCF6422397711F0091D419 /* SettingsVC.swift */; }; B8D64FBB25BA78310029CFC0 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; - B8D64FBC25BA78310029CFC0 /* SessionProtocolKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; }; B8D64FBD25BA78310029CFC0 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; B8D64FBE25BA78310029CFC0 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; B8D64FC725BA78520029CFC0 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; - B8D64FC825BA78520029CFC0 /* SessionProtocolKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; }; - B8D64FC925BA78520029CFC0 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; B8D64FCB25BA78A90029CFC0 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; + B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D84EA225DF745A005A043E /* LinkPreviewState.swift */; }; + B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D84ECE25E3108A005A043E /* ExpandingAttachmentsButton.swift */; }; B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; B8FF8DAF25C0D00F004D1F22 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */; }; @@ -318,17 +299,21 @@ C300A60D2554B31900555489 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5CE2553860700C340D1 /* Logging.swift */; }; C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */ = {isa = PBXBuildFile; fileRef = C300A6312554B6D100555489 /* NSDate+Timestamp.mm */; }; C300A63B2554B72200555489 /* NSDate+Timestamp.h in Headers */ = {isa = PBXBuildFile; fileRef = C300A6302554B68200555489 /* NSDate+Timestamp.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C59247F214E001123EF /* UIView+Glow.swift */; }; C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */; }; C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; }; C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */; }; C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */; }; - C32A025A25A7FC55000ED5D4 /* ClosedGroupsV2Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32A025925A7FC55000ED5D4 /* ClosedGroupsV2Migration.swift */; }; + C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; }; + C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328250E25CA06020062D0A7 /* VoiceMessageView.swift */; }; + C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328251E25CA3A900062D0A7 /* QuoteView.swift */; }; + C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328252F25CA55360062D0A7 /* ContextMenuWindow.swift */; }; + C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328253F25CA55880062D0A7 /* ContextMenuVC.swift */; }; + C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */; }; + C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */; }; C32A026325A801AA000ED5D4 /* NSData+messagePadding.m in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D4825589FF20043A11F /* NSData+messagePadding.m */; }; C32A026C25A801AF000ED5D4 /* NSData+messagePadding.h in Headers */ = {isa = PBXBuildFile; fileRef = C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C32A027D25A80423000ED5D4 /* SessionProtocolKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; }; - C32A027E25A80428000ED5D4 /* SessionProtocolKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; }; - C32A027F25A80432000ED5D4 /* SessionProtocolKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; }; C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */; }; C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */; }; C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAD3255A580300E217F9 /* TSThread.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -548,8 +533,10 @@ C35D0DAB25AE5BDE00B6BF49 /* SettingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D0DAA25AE5BDE00B6BF49 /* SettingRow.swift */; }; C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */; }; C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35E8AAD2485E51D00ACB629 /* IP2Country.swift */; }; - C3645350252449260045C478 /* VoiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C364534F252449260045C478 /* VoiceMessageView.swift */; }; C364535C252467900045C478 /* AudioUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C364535B252467900045C478 /* AudioUtilities.swift */; }; + C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEE125DA26740073A857 /* LinkPreviewModal.swift */; }; + C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */; }; + C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */; }; C37F5385255B94F6002AEA92 /* SelectRecipientViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF34E255B6DC8007E1867 /* SelectRecipientViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -714,6 +701,7 @@ C3A7222A2558C1E40043A11F /* DotNetAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A722292558C1E40043A11F /* DotNetAPI.swift */; }; C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A7225D2558C38D0043A11F /* Promise+Retaining.swift */; }; C3A7229C2558E4310043A11F /* OpenGroupMessage+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A7229B2558E4310043A11F /* OpenGroupMessage+Conversion.swift */; }; + C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */; }; C3AABDDF2553ECF00042FF4C /* Array+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Description.swift */; }; C3AAFFE825AE975D0089E6DD /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFDE25AE96FF0089E6DD /* ConfigurationMessage+Convenience.swift */; }; C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AAFFF125AE99710089E6DD /* AppDelegate.swift */; }; @@ -754,11 +742,8 @@ C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */; }; - C3C2A8662553B41A00C340D1 /* SessionProtocolKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A8642553B41A00C340D1 /* SessionProtocolKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C3C2A86A2553B41A00C340D1 /* SessionProtocolKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2ABD12553C6C900C340D1 /* Data+SecureRandom.swift */; }; C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */; }; - C3C2AC372553CCE600C340D1 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; C3CA3AA2255CDADA00F4C6D4 /* english.txt in Resources */ = {isa = PBXBuildFile; fileRef = C3CA3AA1255CDADA00F4C6D4 /* english.txt */; }; C3CA3AB4255CDAE600F4C6D4 /* japanese.txt in Resources */ = {isa = PBXBuildFile; fileRef = C3CA3AB3255CDAE600F4C6D4 /* japanese.txt */; }; C3CA3ABE255CDB0D00F4C6D4 /* portuguese.txt in Resources */ = {isa = PBXBuildFile; fileRef = C3CA3ABD255CDB0D00F4C6D4 /* portuguese.txt */; }; @@ -838,13 +823,6 @@ remoteGlobalIDString = C3C2A6EF25539DE700C340D1; remoteInfo = SessionMessagingKit; }; - B8D64FB525BA78270029CFC0 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D221A080169C9E5E00537ABF /* Project object */; - proxyType = 1; - remoteGlobalIDString = C3C2A8612553B41A00C340D1; - remoteInfo = SessionProtocolKit; - }; B8D64FB725BA78270029CFC0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -866,13 +844,6 @@ remoteGlobalIDString = C3C2A6EF25539DE700C340D1; remoteInfo = SessionMessagingKit; }; - B8D64FC125BA784A0029CFC0 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D221A080169C9E5E00537ABF /* Project object */; - proxyType = 1; - remoteGlobalIDString = C3C2A8612553B41A00C340D1; - remoteInfo = SessionProtocolKit; - }; B8D64FC325BA784A0029CFC0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -922,13 +893,6 @@ remoteGlobalIDString = C3C2A6EF25539DE700C340D1; remoteInfo = SessionMessagingKit; }; - C3C2A8672553B41A00C340D1 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D221A080169C9E5E00537ABF /* Project object */; - proxyType = 1; - remoteGlobalIDString = C3C2A8612553B41A00C340D1; - remoteInfo = SessionProtocolKit; - }; C3D90A5425773A1A002C9DF5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -971,7 +935,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - C3C2A86A2553B41A00C340D1 /* SessionProtocolKit.framework in Embed Frameworks */, C3C2A681255388CC00C340D1 /* SessionUtilitiesKit.framework in Embed Frameworks */, C33FD9B3255A548A00E217F9 /* SignalUtilitiesKit.framework in Embed Frameworks */, C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */, @@ -998,25 +961,18 @@ 340FC87E204DAC8C007AEB0F /* PrivacySettingsTableViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PrivacySettingsTableViewController.m; sourceTree = ""; }; 340FC87F204DAC8C007AEB0F /* OWSBackupSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupSettingsViewController.h; sourceTree = ""; }; 340FC883204DAC8C007AEB0F /* OWSSoundSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSSoundSettingsViewController.m; sourceTree = ""; }; - 340FC884204DAC8C007AEB0F /* AboutTableViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AboutTableViewController.h; sourceTree = ""; }; - 340FC886204DAC8C007AEB0F /* AddToBlockListViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AddToBlockListViewController.m; sourceTree = ""; }; 340FC888204DAC8C007AEB0F /* OWSQRCodeScanningViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSQRCodeScanningViewController.h; sourceTree = ""; }; 340FC88A204DAC8C007AEB0F /* NotificationSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationSettingsViewController.h; sourceTree = ""; }; 340FC88B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationSettingsOptionsViewController.h; sourceTree = ""; }; 340FC88E204DAC8C007AEB0F /* OWSBackupSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupSettingsViewController.m; sourceTree = ""; }; 340FC88F204DAC8C007AEB0F /* PrivacySettingsTableViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PrivacySettingsTableViewController.h; sourceTree = ""; }; - 340FC892204DAC8C007AEB0F /* AddToBlockListViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AddToBlockListViewController.h; sourceTree = ""; }; - 340FC893204DAC8C007AEB0F /* AboutTableViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AboutTableViewController.m; sourceTree = ""; }; 340FC894204DAC8C007AEB0F /* OWSSoundSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSoundSettingsViewController.h; sourceTree = ""; }; 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSQRCodeScanningViewController.m; sourceTree = ""; }; 340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSConversationSettingsViewDelegate.h; sourceTree = ""; }; 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSConversationSettingsViewController.m; sourceTree = ""; }; 340FC8A0204DAC8D007AEB0F /* OWSConversationSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSConversationSettingsViewController.h; sourceTree = ""; }; - 34129B8521EF8779005457A8 /* LinkPreviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; 341341ED2187467900192D59 /* ConversationViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewModel.h; sourceTree = ""; }; 341341EE2187467900192D59 /* ConversationViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewModel.m; sourceTree = ""; }; - 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSQuotedMessageView.m; sourceTree = ""; }; - 34277A5D20751BDC006049F2 /* OWSQuotedMessageView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSQuotedMessageView.h; sourceTree = ""; }; 3427C64120F500DE00EEC730 /* OWSMessageTimerView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageTimerView.h; sourceTree = ""; }; 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageTimerView.m; sourceTree = ""; }; 3430FE171F7751D4000EC51B /* GiphyAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyAPI.swift; sourceTree = ""; }; @@ -1026,8 +982,6 @@ 34330AA11E79686200DF2FB9 /* OWSProgressView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSProgressView.h; sourceTree = ""; }; 34330AA21E79686200DF2FB9 /* OWSProgressView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSProgressView.m; sourceTree = ""; }; 34386A53207D271C009F5D9C /* NeverClearView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NeverClearView.swift; sourceTree = ""; }; - 343A65961FC4CFE6000477A1 /* ConversationScrollButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationScrollButton.m; sourceTree = ""; }; - 343A65971FC4CFE7000477A1 /* ConversationScrollButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationScrollButton.h; sourceTree = ""; }; 3441FD9E21A3604F00BB9542 /* BackupRestoreViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackupRestoreViewController.swift; sourceTree = ""; }; 34480B341FD0929200BC14EF /* ShareAppExtensionContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ShareAppExtensionContext.h; sourceTree = ""; }; 34480B351FD0929200BC14EF /* ShareAppExtensionContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ShareAppExtensionContext.m; sourceTree = ""; }; @@ -1043,11 +997,7 @@ 34661FB720C1C0D60056EDD6 /* message_sent.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; name = message_sent.aiff; path = Session/Meta/AudioFiles/message_sent.aiff; sourceTree = SOURCE_ROOT; }; 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CropScaleImageViewController.swift; sourceTree = ""; }; 347850561FD86544007B8332 /* SAEFailedViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SAEFailedViewController.swift; sourceTree = ""; }; - 348570A620F67574004FF32B /* OWSMessageHeaderView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageHeaderView.m; sourceTree = ""; }; - 348570A720F67574004FF32B /* OWSMessageHeaderView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageHeaderView.h; sourceTree = ""; }; - 3488F9352191CC4000E524CC /* ConversationMediaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMediaView.swift; sourceTree = ""; }; - 3496744B2076768600080B5F /* OWSMessageBubbleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageBubbleView.h; sourceTree = ""; }; - 3496744C2076768700080B5F /* OWSMessageBubbleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageBubbleView.m; sourceTree = ""; }; + 3488F9352191CC4000E524CC /* MediaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; 3496744E2076ACCE00080B5F /* LongTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LongTextViewController.swift; sourceTree = ""; }; 34969559219B605E00DCFE74 /* ImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCollectionPickerController.swift; sourceTree = ""; }; @@ -1066,66 +1016,32 @@ 3496956C21A301A100DCFE74 /* OWSBackupImportJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupImportJob.h; sourceTree = ""; }; 3496956D21A301A100DCFE74 /* OWSBackupIO.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupIO.h; sourceTree = ""; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; - 34A8B3502190A40E00218A25 /* MediaAlbumCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumCellView.swift; sourceTree = ""; }; + 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMessageMapping.swift; sourceTree = ""; }; - 34AC0A21211C829E00997B47 /* OWSLabel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSLabel.m; sourceTree = ""; }; - 34AC0A22211C829E00997B47 /* OWSLabel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSLabel.h; sourceTree = ""; }; 34B0796B1FCF46B000E248C2 /* MainAppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MainAppContext.m; sourceTree = ""; }; 34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = ""; }; - 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = ""; }; 34B6A90A218BA1D0007C4606 /* typing-animation.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "typing-animation.gif"; sourceTree = ""; }; 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = ""; }; 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerLayout.swift; sourceTree = ""; }; 34C3C78C20409F320000134C /* Opening.m4r */ = {isa = PBXFileReference; lastKnownFileType = file; path = Opening.m4r; sourceTree = ""; }; 34C3C78E2040A4F70000134C /* sonarping.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = sonarping.mp3; path = Session/Meta/AudioFiles/sonarping.mp3; sourceTree = SOURCE_ROOT; }; - 34CA1C261F7156F300E51C51 /* MessageDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageDetailViewController.swift; sourceTree = ""; }; 34CF0783203E6B77005C4D61 /* busy_tone_ansi.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = busy_tone_ansi.caf; path = Session/Meta/AudioFiles/busy_tone_ansi.caf; sourceTree = SOURCE_ROOT; }; 34CF0784203E6B77005C4D61 /* ringback_tone_ansi.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = ringback_tone_ansi.caf; path = Session/Meta/AudioFiles/ringback_tone_ansi.caf; sourceTree = SOURCE_ROOT; }; 34CF0786203E6B78005C4D61 /* end_call_tone_cept.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = end_call_tone_cept.caf; path = Session/Meta/AudioFiles/end_call_tone_cept.caf; sourceTree = SOURCE_ROOT; }; 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerCell.swift; sourceTree = ""; }; 34D1F0511F7E8EA30066283D /* GiphyDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GiphyDownloader.swift; sourceTree = ""; }; - 34D1F0671F8678AA0066283D /* ConversationInputTextView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationInputTextView.h; sourceTree = ""; }; - 34D1F0681F8678AA0066283D /* ConversationInputTextView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationInputTextView.m; sourceTree = ""; }; - 34D1F0691F8678AA0066283D /* ConversationInputToolbar.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationInputToolbar.h; sourceTree = ""; }; - 34D1F06A1F8678AA0066283D /* ConversationInputToolbar.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationInputToolbar.m; sourceTree = ""; }; - 34D1F06D1F8678AA0066283D /* ConversationViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewController.h; sourceTree = ""; }; - 34D1F06E1F8678AA0066283D /* ConversationViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewController.m; sourceTree = ""; }; 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewItem.h; sourceTree = ""; }; 34D1F0701F8678AA0066283D /* ConversationViewItem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItem.m; sourceTree = ""; }; - 34D1F0711F8678AA0066283D /* ConversationViewLayout.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewLayout.h; sourceTree = ""; }; - 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewLayout.m; sourceTree = ""; }; - 34D1F0961F867BFC0066283D /* ConversationViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewCell.h; sourceTree = ""; }; - 34D1F0971F867BFC0066283D /* ConversationViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewCell.m; sourceTree = ""; }; - 34D1F0A11F867BFC0066283D /* OWSMessageCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageCell.h; sourceTree = ""; }; - 34D1F0A21F867BFC0066283D /* OWSMessageCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageCell.m; sourceTree = ""; }; - 34D1F0A51F867BFC0066283D /* OWSSystemMessageCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSystemMessageCell.h; sourceTree = ""; }; - 34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSSystemMessageCell.m; sourceTree = ""; }; - 34D1F0B21F86D31D0066283D /* ConversationCollectionView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationCollectionView.h; sourceTree = ""; }; - 34D1F0B31F86D31D0066283D /* ConversationCollectionView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationCollectionView.m; sourceTree = ""; }; - 34D1F0B51F87F8850066283D /* OWSGenericAttachmentView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSGenericAttachmentView.h; sourceTree = ""; }; - 34D1F0B61F87F8850066283D /* OWSGenericAttachmentView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSGenericAttachmentView.m; sourceTree = ""; }; - 34D1F0BB1F8D108C0066283D /* AttachmentUploadView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AttachmentUploadView.h; sourceTree = ""; }; - 34D1F0BC1F8D108C0066283D /* AttachmentUploadView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AttachmentUploadView.m; sourceTree = ""; }; 34D1F0BF1F8EC1760066283D /* MessageRecipientStatusUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRecipientStatusUtils.swift; sourceTree = ""; }; 34D2CCD82062E7D000CB1A14 /* OWSScreenLockUI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSScreenLockUI.h; sourceTree = ""; }; 34D2CCD92062E7D000CB1A14 /* OWSScreenLockUI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSScreenLockUI.m; sourceTree = ""; }; 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AvatarViewHelper.h; sourceTree = ""; }; 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AvatarViewHelper.m; sourceTree = ""; }; - 34D920E520E179C100D51158 /* OWSMessageFooterView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageFooterView.h; sourceTree = ""; }; - 34D920E620E179C200D51158 /* OWSMessageFooterView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageFooterView.m; sourceTree = ""; }; 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; - 34DBEFFF206BD5A400025978 /* OWSMessageTextView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageTextView.m; sourceTree = ""; }; - 34DBF000206BD5A400025978 /* OWSMessageTextView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageTextView.h; sourceTree = ""; }; - 34DBF001206BD5A500025978 /* OWSBubbleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBubbleView.m; sourceTree = ""; }; - 34DBF002206BD5A500025978 /* OWSBubbleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBubbleView.h; sourceTree = ""; }; - 34DBF005206C3CB100025978 /* OWSBubbleShapeView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBubbleShapeView.h; sourceTree = ""; }; - 34DBF006206C3CB200025978 /* OWSBubbleShapeView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBubbleShapeView.m; sourceTree = ""; }; 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioProgressView.swift; sourceTree = ""; }; - 34EA693F2194933900702471 /* MediaDownloadView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaDownloadView.swift; sourceTree = ""; }; - 34EA69412194DE7F00702471 /* MediaUploadView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaUploadView.swift; sourceTree = ""; }; 34F308A01ECB469700BB7697 /* OWSBezierPathView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBezierPathView.h; sourceTree = ""; }; 34F308A11ECB469700BB7697 /* OWSBezierPathView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBezierPathView.m; sourceTree = ""; }; 36098A00B2C7DB91D85A4AE3 /* Pods-Session.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Session.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Session/Pods-Session.debug.xcconfig"; sourceTree = ""; }; @@ -1150,7 +1066,6 @@ 454A84032059C787008B8C75 /* MediaTileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTileViewController.swift; sourceTree = ""; }; 455A16DB1F1FEA0000F86704 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; 455A16DC1F1FEA0000F86704 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; - 457F671A20746193000EABCD /* QuotedReplyPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReplyPreview.swift; sourceTree = ""; }; 45847E861E4283C30080EAB3 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; 45A2F004204473A3002E978A /* NewMessage.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; name = NewMessage.aifc; path = Session/Meta/AudioFiles/NewMessage.aifc; sourceTree = SOURCE_ROOT; }; 45A6DAD51EBBF85500893231 /* ReminderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReminderView.swift; sourceTree = ""; }; @@ -1189,7 +1104,6 @@ 45CD81EE1DC030E7004C9430 /* SyncPushTokensJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncPushTokensJob.swift; sourceTree = ""; }; 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarqueeLabel.swift; sourceTree = ""; }; 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaPageViewController.swift; path = "Session/Media Viewing & Editing/MediaPageViewController.swift"; sourceTree = SOURCE_ROOT; }; - 4C043929220A9EC800BAEA63 /* VoiceNoteLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNoteLock.swift; sourceTree = ""; }; 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridViewCell.swift; sourceTree = ""; }; 4C1D2337218B6BA000A0598F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; @@ -1205,11 +1119,9 @@ 4C9CA25C217E676900607C63 /* ZXingObjC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZXingObjC.framework; path = ThirdParty/Carthage/Build/iOS/ZXingObjC.framework; sourceTree = ""; }; 4CA46F4B219CCC630038ABDE /* CaptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionView.swift; sourceTree = ""; }; 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureViewController.swift; sourceTree = ""; }; - 4CB5F26820F7D060004D1B42 /* MessageActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions.swift; sourceTree = ""; }; 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateNag.swift; sourceTree = ""; }; 4CC613352227A00400E21A3A /* ConversationSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearch.swift; sourceTree = ""; }; 4CFE6B6B21F92BA700006701 /* LegacyNotificationsAdaptee.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyNotificationsAdaptee.swift; sourceTree = ""; }; - 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuActionsViewController.swift; sourceTree = ""; }; 53D547348A367C8A14D37FC0 /* Pods_SignalUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5F3070F3395081DD0EB4F933 /* Pods-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUtilitiesKit/Pods-SignalUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; 62ED73E38E0EC8506A9131AD /* Pods_SessionNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1278,13 +1190,26 @@ B69CD25019773E79005CE69A /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; B6B226961BE4B7D200860F4D /* ContactsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ContactsUI.framework; path = System/Library/Frameworks/ContactsUI.framework; sourceTree = SDKROOT; }; B6FE7EB61ADD62FA00A6D22F /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = System/Library/Frameworks/PushKit.framework; sourceTree = SDKROOT; }; + B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoaderView.swift; sourceTree = ""; }; + B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = ""; }; B80A579E23DFF1F300876683 /* NewClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewClosedGroupVC.swift; sourceTree = ""; }; + B821494525D4D6FF009C0F2A /* URLModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLModal.swift; sourceTree = ""; }; + B821494E25D4E163009C0F2A /* BodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyTextView.swift; sourceTree = ""; }; + B82149B725D60393009C0F2A /* BlockedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedModal.swift; sourceTree = ""; }; + B82149C025D605C6009C0F2A /* InfoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBanner.swift; sourceTree = ""; }; + B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesTableView.swift; sourceTree = ""; }; + B8269D2825C7A4B400488AB4 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = ""; }; + B8269D3225C7A8C600488AB4 /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; + B8269D3C25C7B34D00488AB4 /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; B82B40872399EB0E00A248E7 /* LandingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingVC.swift; sourceTree = ""; }; B82B40892399EC0600A248E7 /* FakeChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeChatView.swift; sourceTree = ""; }; B82B408B239A068800A248E7 /* RegisterVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterVC.swift; sourceTree = ""; }; B82B408D239DC00D00A248E7 /* DisplayNameVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayNameVC.swift; sourceTree = ""; }; B82B408F239DD75000A248E7 /* RestoreVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreVC.swift; sourceTree = ""; }; - B82B4093239DF15900A248E7 /* ConversationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleView.swift; sourceTree = ""; }; + B835246D25C38ABF0089A44F /* ConversationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationVC.swift; sourceTree = ""; }; + B835247825C38D880089A44F /* MessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCell.swift; sourceTree = ""; }; + B835249A25C3AB650089A44F /* VisibleMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleMessageCell.swift; sourceTree = ""; }; + B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoMessageCell.swift; sourceTree = ""; }; B837867F2586D296003CE78E /* KeyPairMigrationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairMigrationSheet.swift; sourceTree = ""; }; B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationButtonSet.swift; sourceTree = ""; }; B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; @@ -1292,11 +1217,16 @@ B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSQuotedReplyModel+Conversion.swift"; sourceTree = ""; }; B84664F4235022F30083A1CD /* MentionUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionUtilities.swift; sourceTree = ""; }; B847570023D568EB00759540 /* SignalServiceKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SignalServiceKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; + B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePictureVC.swift; sourceTree = ""; }; B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedReminderView.swift; sourceTree = ""; }; B85357C223A1BD1200AAF6CD /* SeedVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedVC.swift; sourceTree = ""; }; B8544E3023D16CA500299F14 /* DeviceUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtilities.swift; sourceTree = ""; }; - B8544E3223D50E4900299F14 /* AppearanceUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceUtilities.swift; sourceTree = ""; }; + B8544E3223D50E4900299F14 /* SNAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SNAppearance.swift; sourceTree = ""; }; B8566C62256F55930045A0B9 /* OWSLinkPreview+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSLinkPreview+Conversion.swift"; sourceTree = ""; }; + B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationVC+Interaction.swift"; sourceTree = ""; }; + B8569AD225CBA13D00DBA3DB /* MediaTextOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTextOverlayView.swift; sourceTree = ""; }; + B8569AE225CBB19A00DBA3DB /* DocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentView.swift; sourceTree = ""; }; B85A68B02587141A008CC492 /* Storage+Resetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Resetting.swift"; sourceTree = ""; }; B86BD08323399ACF000F5AE3 /* Modal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modal.swift; sourceTree = ""; }; B86BD08523399CEF000F5AE3 /* SeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedModal.swift; sourceTree = ""; }; @@ -1310,11 +1240,11 @@ B88847BB23E10BC6009836D2 /* GroupMembersVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMembersVC.swift; sourceTree = ""; }; B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = ""; }; + B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; B8A14D6F2589CE9000E70D57 /* KeyPairMigrationSuccessSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairMigrationSuccessSheet.swift; sourceTree = ""; }; B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; B8AE760925ABFB00001A84D2 /* GeneralUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneralUtilities.h; sourceTree = ""; }; B8AE760A25ABFB5A001A84D2 /* GeneralUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GeneralUtilities.m; sourceTree = ""; }; - B8B26C8E234D629C004ED98C /* MentionCandidateSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionCandidateSelectionView.swift; sourceTree = ""; }; B8B32020258B1A650020074B /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; B8B32032258B235D0020074B /* Storage+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Contacts.swift"; sourceTree = ""; }; B8B32044258C117C0020074B /* ContactsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsMigration.swift; sourceTree = ""; }; @@ -1336,16 +1266,14 @@ B8C2B331256376F000551B4D /* ThreadUtil.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ThreadUtil.m; sourceTree = ""; }; B8C2B33B2563770800551B4D /* ThreadUtil.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ThreadUtil.h; sourceTree = ""; }; B8C9689023FA1401005F64E0 /* AppMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMode.swift; sourceTree = ""; }; - B8CA010025A293260091AF73 /* ClosedGroupSenderKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupSenderKey.swift; sourceTree = ""; }; - B8CA010A25A293530091AF73 /* ClosedGroupRatchet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupRatchet.swift; sourceTree = ""; }; - B8CA011425A293800091AF73 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; - B8CA011E25A2939F0091AF73 /* SharedSenderKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedSenderKeys.swift; sourceTree = ""; }; - B8CA014025A293EE0091AF73 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; B8CCF6342396005F0091D419 /* SpaceMono-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Regular.ttf"; sourceTree = ""; }; B8CCF63623961D6D0091D419 /* NewPrivateChatVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPrivateChatVC.swift; sourceTree = ""; }; B8CCF638239721E20091D419 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; - B8CCF63E23975CFB0091D419 /* JoinPublicChatVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinPublicChatVC.swift; sourceTree = ""; }; + B8CCF63E23975CFB0091D419 /* JoinOpenGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinOpenGroupVC.swift; sourceTree = ""; }; B8CCF6422397711F0091D419 /* SettingsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsVC.swift; sourceTree = ""; }; + B8D84E9325DF72AF005A043E /* ConversationViewAction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ConversationViewAction.h; sourceTree = ""; }; + B8D84EA225DF745A005A043E /* LinkPreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewState.swift; sourceTree = ""; }; + B8D84ECE25E3108A005A043E /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+ClosedGroups.swift"; sourceTree = ""; }; B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Jobs.swift"; sourceTree = ""; }; B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OpenGroups.swift"; sourceTree = ""; }; @@ -1368,6 +1296,7 @@ C300A5FB2554B0A000555489 /* MessageReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiver.swift; sourceTree = ""; }; C300A6302554B68200555489 /* NSDate+Timestamp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDate+Timestamp.h"; sourceTree = ""; }; C300A6312554B6D100555489 /* NSDate+Timestamp.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "NSDate+Timestamp.mm"; sourceTree = ""; }; + C302093D25DCBF07001F572D /* MentionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSelectionView.swift; sourceTree = ""; }; C31A6C59247F214E001123EF /* UIView+Glow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Glow.swift"; sourceTree = ""; }; C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; C31D1DDC25217014005D4DA8 /* UserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCell.swift; sourceTree = ""; }; @@ -1375,7 +1304,12 @@ C31D1DE8252172D4005D4DA8 /* ContactUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactUtilities.swift; sourceTree = ""; }; C31F812525258FB000DD9FD9 /* Storage+VolumeSamples.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+VolumeSamples.swift"; sourceTree = ""; }; C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPairUtilities.swift; sourceTree = ""; }; - C32A025925A7FC55000ED5D4 /* ClosedGroupsV2Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupsV2Migration.swift; sourceTree = ""; }; + C328250E25CA06020062D0A7 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = ""; }; + C328251E25CA3A900062D0A7 /* QuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView.swift; sourceTree = ""; }; + C328252F25CA55360062D0A7 /* ContextMenuWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuWindow.swift; sourceTree = ""; }; + C328253F25CA55880062D0A7 /* ContextMenuVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuVC.swift; sourceTree = ""; }; + C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+Action.swift"; sourceTree = ""; }; + C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+ActionView.swift"; sourceTree = ""; }; C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Handling.swift"; sourceTree = ""; }; C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSQuotedMessage+Conversion.swift"; sourceTree = ""; }; C32C5FD5256E0346003C73A2 /* Notification+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Thread.swift"; sourceTree = ""; }; @@ -1598,8 +1532,10 @@ C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIEdgeInsets.swift; sourceTree = ""; }; C35E8AA22485C72300ACB629 /* SwiftCSV.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftCSV.framework; path = ThirdParty/Carthage/Build/iOS/SwiftCSV.framework; sourceTree = ""; }; C35E8AAD2485E51D00ACB629 /* IP2Country.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IP2Country.swift; sourceTree = ""; }; - C364534F252449260045C478 /* VoiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageView.swift; sourceTree = ""; }; C364535B252467900045C478 /* AudioUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioUtilities.swift; sourceTree = ""; }; + C374EEE125DA26740073A857 /* LinkPreviewModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewModal.swift; sourceTree = ""; }; + C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleView.swift; sourceTree = ""; }; + C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentPointer+Conversion.swift"; sourceTree = ""; }; C37F53E8255BA9BB002AEA92 /* Environment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Environment.h; sourceTree = ""; }; @@ -1779,6 +1715,7 @@ C3A722292558C1E40043A11F /* DotNetAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DotNetAPI.swift; sourceTree = ""; }; C3A7225D2558C38D0043A11F /* Promise+Retaining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Retaining.swift"; sourceTree = ""; }; C3A7229B2558E4310043A11F /* OpenGroupMessage+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupMessage+Conversion.swift"; sourceTree = ""; }; + C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionMissingModal.swift; sourceTree = ""; }; C3AA6BB824CE8F1B002358B6 /* Migrating Translations from Android.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = "Migrating Translations from Android.md"; path = "Meta/Translations/Migrating Translations from Android.md"; sourceTree = ""; }; C3AAFFCB25AE92150089E6DD /* OpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManager.swift; sourceTree = ""; }; C3AAFFDE25AE96FF0089E6DD /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; @@ -1827,9 +1764,6 @@ C3C2A7702553A41E00C340D1 /* ControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessage.swift; sourceTree = ""; }; C3C2A7822553AAF200C340D1 /* SNProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNProto.swift; sourceTree = ""; }; C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionProtos.pb.swift; sourceTree = ""; }; - C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SessionProtocolKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - C3C2A8642553B41A00C340D1 /* SessionProtocolKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionProtocolKit.h; sourceTree = ""; }; - C3C2A8652553B41A00C340D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C3C2ABD12553C6C900C340D1 /* Data+SecureRandom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+SecureRandom.swift"; sourceTree = ""; }; C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Trimming.swift"; sourceTree = ""; }; C3C3CF8824D8EED300E1CCE7 /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; @@ -1892,9 +1826,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C32824D325C9F9790062D0A7 /* SessionSnodeKit.framework in Frameworks */, B8D64FC725BA78520029CFC0 /* SessionMessagingKit.framework in Frameworks */, - B8D64FC825BA78520029CFC0 /* SessionProtocolKit.framework in Frameworks */, - B8D64FC925BA78520029CFC0 /* SessionSnodeKit.framework in Frameworks */, C3D90A5C25773A25002C9DF5 /* SessionUtilitiesKit.framework in Frameworks */, C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */, B8D64FCB25BA78A90029CFC0 /* SignalUtilitiesKit.framework in Frameworks */, @@ -1907,7 +1840,6 @@ buildActionMask = 2147483647; files = ( B8D64FBB25BA78310029CFC0 /* SessionMessagingKit.framework in Frameworks */, - B8D64FBC25BA78310029CFC0 /* SessionProtocolKit.framework in Frameworks */, B8D64FBD25BA78310029CFC0 /* SessionSnodeKit.framework in Frameworks */, B8D64FBE25BA78310029CFC0 /* SessionUtilitiesKit.framework in Frameworks */, C38EF00C255B61CC007E1867 /* SignalUtilitiesKit.framework in Frameworks */, @@ -1927,7 +1859,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C32A027E25A80428000ED5D4 /* SessionProtocolKit.framework in Frameworks */, C38EF48A255B7E3F007E1867 /* SessionUIKit.framework in Frameworks */, C33FD9C2255A54EF00E217F9 /* SessionMessagingKit.framework in Frameworks */, C33FD9C4255A54EF00E217F9 /* SessionSnodeKit.framework in Frameworks */, @@ -1957,28 +1888,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C32A027D25A80423000ED5D4 /* SessionProtocolKit.framework in Frameworks */, 5DF9AB212C6DB1E8BE70EFF6 /* Pods_SessionMessagingKit.framework in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionSnodeKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - C3C2A85F2553B41A00C340D1 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - A33A4BA9D050805FE156E3ED /* Pods_SessionProtocolKit.framework in Frameworks */, - C3C2AC372553CCE600C340D1 /* SessionUtilitiesKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D221A086169C9E5E00537ABF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */, B8FF8DAF25C0D00F004D1F22 /* SessionUtilitiesKit.framework in Frameworks */, - C32A027F25A80432000ED5D4 /* SessionProtocolKit.framework in Frameworks */, C37F54DC255BB84A002AEA92 /* SessionSnodeKit.framework in Frameworks */, C37F5414255BAFA7002AEA92 /* SignalUtilitiesKit.framework in Frameworks */, 455A16DD1F1FEA0000F86704 /* Metal.framework in Frameworks */, @@ -2125,7 +2045,7 @@ 450DF2041E0D74AC003D14BE /* Platform.swift */, 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */, 34D1F0BF1F8EC1760066283D /* MessageRecipientStatusUtils.swift */, - B8544E3223D50E4900299F14 /* AppearanceUtilities.swift */, + B8544E3223D50E4900299F14 /* SNAppearance.swift */, C364535B252467900045C478 /* AudioUtilities.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */, @@ -2236,6 +2156,86 @@ path = ..; sourceTree = ""; }; + B8041A7325C8F758003C2166 /* Content Views */ = { + isa = PBXGroup; + children = ( + 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */, + B8569AD225CBA13D00DBA3DB /* MediaTextOverlayView.swift */, + 3488F9352191CC4000E524CC /* MediaView.swift */, + B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */, + C328251E25CA3A900062D0A7 /* QuoteView.swift */, + 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, + C328250E25CA06020062D0A7 /* VoiceMessageView.swift */, + B8569AE225CBB19A00DBA3DB /* DocumentView.swift */, + B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */, + B8D84EA225DF745A005A043E /* LinkPreviewState.swift */, + ); + path = "Content Views"; + sourceTree = ""; + }; + B821493625D4D6A7009C0F2A /* Views & Modals */ = { + isa = PBXGroup; + children = ( + B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */, + C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */, + B821494525D4D6FF009C0F2A /* URLModal.swift */, + B821494E25D4E163009C0F2A /* BodyTextView.swift */, + B82149B725D60393009C0F2A /* BlockedModal.swift */, + C374EEE125DA26740073A857 /* LinkPreviewModal.swift */, + B82149C025D605C6009C0F2A /* InfoBanner.swift */, + B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */, + C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */, + ); + path = "Views & Modals"; + sourceTree = ""; + }; + B835246C25C38AA20089A44F /* Conversations */ = { + isa = PBXGroup; + children = ( + B835246D25C38ABF0089A44F /* ConversationVC.swift */, + B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */, + 3496744E2076ACCE00080B5F /* LongTextViewController.swift */, + 4CC613352227A00400E21A3A /* ConversationSearch.swift */, + B8D84E9325DF72AF005A043E /* ConversationViewAction.h */, + 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, + 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, + 341341ED2187467900192D59 /* ConversationViewModel.h */, + 341341EE2187467900192D59 /* ConversationViewModel.m */, + 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */, + B887C38125C7C79700E11DAE /* Input View */, + B835247725C38D190089A44F /* Message Cells */, + C328252E25CA54F70062D0A7 /* Context Menu */, + B821493625D4D6A7009C0F2A /* Views & Modals */, + C302094625DCDFD3001F572D /* Settings */, + ); + path = Conversations; + sourceTree = ""; + }; + B835247725C38D190089A44F /* Message Cells */ = { + isa = PBXGroup; + children = ( + B835247825C38D880089A44F /* MessageCell.swift */, + B835249A25C3AB650089A44F /* VisibleMessageCell.swift */, + B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */, + B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */, + B8041A7325C8F758003C2166 /* Content Views */, + ); + path = "Message Cells"; + sourceTree = ""; + }; + B887C38125C7C79700E11DAE /* Input View */ = { + isa = PBXGroup; + children = ( + B8269D2825C7A4B400488AB4 /* InputView.swift */, + B8269D3225C7A8C600488AB4 /* InputViewButton.swift */, + B8269D3C25C7B34D00488AB4 /* InputTextView.swift */, + C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */, + C302093D25DCBF07001F572D /* MentionSelectionView.swift */, + B8D84ECE25E3108A005A043E /* ExpandingAttachmentsButton.swift */, + ); + path = "Input View"; + sourceTree = ""; + }; B8A582AB258C64E800AFD84C /* Database */ = { isa = PBXGroup; children = ( @@ -2457,6 +2457,19 @@ path = "Sending & Receiving"; sourceTree = ""; }; + C302094625DCDFD3001F572D /* Settings */ = { + isa = PBXGroup; + children = ( + 340FC8A0204DAC8D007AEB0F /* OWSConversationSettingsViewController.h */, + 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */, + 340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */, + B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */, + 3427C64120F500DE00EEC730 /* OWSMessageTimerView.h */, + 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */, + ); + path = Settings; + sourceTree = ""; + }; C31C219B255BC92200EC2D66 /* Meta */ = { isa = PBXGroup; children = ( @@ -2477,6 +2490,17 @@ path = Meta; sourceTree = ""; }; + C328252E25CA54F70062D0A7 /* Context Menu */ = { + isa = PBXGroup; + children = ( + C328252F25CA55360062D0A7 /* ContextMenuWindow.swift */, + C328253F25CA55880062D0A7 /* ContextMenuVC.swift */, + C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */, + C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */, + ); + path = "Context Menu"; + sourceTree = ""; + }; C32B405424A961E1001117B5 /* Dependencies */ = { isa = PBXGroup; children = ( @@ -2537,6 +2561,7 @@ C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */, C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */, B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */, + 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, ); path = Signal; sourceTree = ""; @@ -2790,8 +2815,6 @@ C360969125AD1765008B62B2 /* Settings */ = { isa = PBXGroup; children = ( - 340FC884204DAC8C007AEB0F /* AboutTableViewController.h */, - 340FC893204DAC8C007AEB0F /* AboutTableViewController.m */, 340FC88B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.h */, 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */, 340FC88A204DAC8C007AEB0F /* NotificationSettingsViewController.h */, @@ -2821,7 +2844,7 @@ C360969B25AD180B008B62B2 /* Open Groups */ = { isa = PBXGroup; children = ( - B8CCF63E23975CFB0091D419 /* JoinPublicChatVC.swift */, + B8CCF63E23975CFB0091D419 /* JoinOpenGroupVC.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -2844,42 +2867,6 @@ path = "Basic Chats"; sourceTree = ""; }; - C36096AE25AD1909008B62B2 /* Conversations */ = { - isa = PBXGroup; - children = ( - C36096B825AD196A008B62B2 /* Views & Cells */, - 340FC892204DAC8C007AEB0F /* AddToBlockListViewController.h */, - 340FC886204DAC8C007AEB0F /* AddToBlockListViewController.m */, - 34D1F0B21F86D31D0066283D /* ConversationCollectionView.h */, - 34D1F0B31F86D31D0066283D /* ConversationCollectionView.m */, - 34D1F0671F8678AA0066283D /* ConversationInputTextView.h */, - 34D1F0681F8678AA0066283D /* ConversationInputTextView.m */, - 34D1F0691F8678AA0066283D /* ConversationInputToolbar.h */, - 34D1F06A1F8678AA0066283D /* ConversationInputToolbar.m */, - 34ABC0E321DD20C500ED9469 /* ConversationMessageMapping.swift */, - 343A65971FC4CFE7000477A1 /* ConversationScrollButton.h */, - 343A65961FC4CFE6000477A1 /* ConversationScrollButton.m */, - B82B4093239DF15900A248E7 /* ConversationTitleView.swift */, - 34D1F06D1F8678AA0066283D /* ConversationViewController.h */, - 34D1F06E1F8678AA0066283D /* ConversationViewController.m */, - 34D1F06F1F8678AA0066283D /* ConversationViewItem.h */, - 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, - 34D1F0711F8678AA0066283D /* ConversationViewLayout.h */, - 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */, - 341341ED2187467900192D59 /* ConversationViewModel.h */, - 341341EE2187467900192D59 /* ConversationViewModel.m */, - 4CC613352227A00400E21A3A /* ConversationSearch.swift */, - 3496744E2076ACCE00080B5F /* LongTextViewController.swift */, - 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */, - 4CB5F26820F7D060004D1B42 /* MessageActions.swift */, - 34CA1C261F7156F300E51C51 /* MessageDetailViewController.swift */, - 340FC8A0204DAC8D007AEB0F /* OWSConversationSettingsViewController.h */, - 340FC89A204DAC8D007AEB0F /* OWSConversationSettingsViewController.m */, - 340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */, - ); - path = Conversations; - sourceTree = ""; - }; C36096AF25AD1932008B62B2 /* Sheets & Modals */ = { isa = PBXGroup; children = ( @@ -2891,53 +2878,6 @@ path = "Sheets & Modals"; sourceTree = ""; }; - C36096B825AD196A008B62B2 /* Views & Cells */ = { - isa = PBXGroup; - children = ( - 457F671A20746193000EABCD /* QuotedReplyPreview.swift */, - 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, - 4C043929220A9EC800BAEA63 /* VoiceNoteLock.swift */, - 34129B8521EF8779005457A8 /* LinkPreviewView.swift */, - 34D1F0BB1F8D108C0066283D /* AttachmentUploadView.h */, - 34D1F0BC1F8D108C0066283D /* AttachmentUploadView.m */, - 3488F9352191CC4000E524CC /* ConversationMediaView.swift */, - 34D1F0961F867BFC0066283D /* ConversationViewCell.h */, - 34D1F0971F867BFC0066283D /* ConversationViewCell.m */, - 34A8B3502190A40E00218A25 /* MediaAlbumCellView.swift */, - 34EA693F2194933900702471 /* MediaDownloadView.swift */, - 34EA69412194DE7F00702471 /* MediaUploadView.swift */, - B8B26C8E234D629C004ED98C /* MentionCandidateSelectionView.swift */, - 34DBF005206C3CB100025978 /* OWSBubbleShapeView.h */, - 34DBF006206C3CB200025978 /* OWSBubbleShapeView.m */, - 34DBF002206BD5A500025978 /* OWSBubbleView.h */, - 34DBF001206BD5A500025978 /* OWSBubbleView.m */, - 34D1F0B51F87F8850066283D /* OWSGenericAttachmentView.h */, - 34D1F0B61F87F8850066283D /* OWSGenericAttachmentView.m */, - 34AC0A22211C829E00997B47 /* OWSLabel.h */, - 34AC0A21211C829E00997B47 /* OWSLabel.m */, - 3496744B2076768600080B5F /* OWSMessageBubbleView.h */, - 3496744C2076768700080B5F /* OWSMessageBubbleView.m */, - 34D1F0A11F867BFC0066283D /* OWSMessageCell.h */, - 34D1F0A21F867BFC0066283D /* OWSMessageCell.m */, - 34D920E520E179C100D51158 /* OWSMessageFooterView.h */, - 34D920E620E179C200D51158 /* OWSMessageFooterView.m */, - 348570A720F67574004FF32B /* OWSMessageHeaderView.h */, - 348570A620F67574004FF32B /* OWSMessageHeaderView.m */, - 34DBF000206BD5A400025978 /* OWSMessageTextView.h */, - 34DBEFFF206BD5A400025978 /* OWSMessageTextView.m */, - 3427C64120F500DE00EEC730 /* OWSMessageTimerView.h */, - 3427C64220F500DF00EEC730 /* OWSMessageTimerView.m */, - 34277A5D20751BDC006049F2 /* OWSQuotedMessageView.h */, - 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */, - 34D1F0A51F867BFC0066283D /* OWSSystemMessageCell.h */, - 34D1F0A61F867BFC0066283D /* OWSSystemMessageCell.m */, - 34B6A906218B5240007C4606 /* TypingIndicatorCell.swift */, - 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, - C364534F252449260045C478 /* VoiceMessageView.swift */, - ); - path = "Views & Cells"; - sourceTree = ""; - }; C36096B925AD1ACF008B62B2 /* GIFs */ = { isa = PBXGroup; children = ( @@ -3084,7 +3024,6 @@ isa = PBXGroup; children = ( B8B32044258C117C0020074B /* ContactsMigration.swift */, - C32A025925A7FC55000ED5D4 /* ClosedGroupsV2Migration.swift */, C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */, C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */, C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */, @@ -3404,28 +3343,6 @@ path = Generated; sourceTree = ""; }; - C3C2A8632553B41A00C340D1 /* SessionProtocolKit */ = { - isa = PBXGroup; - children = ( - C3C2A8762553B42C00C340D1 /* Meta */, - B8CA010A25A293530091AF73 /* ClosedGroupRatchet.swift */, - B8CA010025A293260091AF73 /* ClosedGroupSenderKey.swift */, - B8CA011425A293800091AF73 /* Configuration.swift */, - B8CA011E25A2939F0091AF73 /* SharedSenderKeys.swift */, - B8CA014025A293EE0091AF73 /* Storage.swift */, - ); - path = SessionProtocolKit; - sourceTree = ""; - }; - C3C2A8762553B42C00C340D1 /* Meta */ = { - isa = PBXGroup; - children = ( - C3C2A8642553B41A00C340D1 /* SessionProtocolKit.h */, - C3C2A8652553B41A00C340D1 /* Info.plist */, - ); - path = Meta; - sourceTree = ""; - }; C3CA3AA0255CDA7000F4C6D4 /* Mnemonic */ = { isa = PBXGroup; children = ( @@ -3581,7 +3498,6 @@ C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, C331FF1C2558F9D300070591 /* SessionUIKit */, C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, - C3C2A8632553B41A00C340D1 /* SessionProtocolKit */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, D221A08C169C9E5E00537ABF /* Frameworks */, @@ -3599,7 +3515,6 @@ C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */, C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */, C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */, - C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */, C331FF1B2558F9D300070591 /* SessionUIKit.framework */, C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */, ); @@ -3665,7 +3580,7 @@ C36096BC25AD1C3E008B62B2 /* Backups */, C36096A525AD18D7008B62B2 /* Basic Chats */, C360969C25AD18BA008B62B2 /* Closed Groups */, - C36096AE25AD1909008B62B2 /* Conversations */, + B835246C25C38AA20089A44F /* Conversations */, C32C5D49256DD522003C73A2 /* Database */, C32B405424A961E1001117B5 /* Dependencies */, C360968E25AD16E8008B62B2 /* Home */, @@ -3850,14 +3765,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - C3C2A85D2553B41A00C340D1 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - C3C2A8662553B41A00C340D1 /* SessionProtocolKit.h in Headers */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -3874,7 +3781,6 @@ ); dependencies = ( B8D64FC025BA784A0029CFC0 /* PBXTargetDependency */, - B8D64FC225BA784A0029CFC0 /* PBXTargetDependency */, B8D64FC425BA784A0029CFC0 /* PBXTargetDependency */, B8D64FC625BA784A0029CFC0 /* PBXTargetDependency */, C3D90A5525773A1A002C9DF5 /* PBXTargetDependency */, @@ -3898,7 +3804,6 @@ ); dependencies = ( B8D64FB425BA78270029CFC0 /* PBXTargetDependency */, - B8D64FB625BA78270029CFC0 /* PBXTargetDependency */, B8D64FB825BA78270029CFC0 /* PBXTargetDependency */, B8D64FBA25BA78270029CFC0 /* PBXTargetDependency */, C3D90A7125773A44002C9DF5 /* PBXTargetDependency */, @@ -4003,25 +3908,6 @@ productReference = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; productType = "com.apple.product-type.framework"; }; - C3C2A8612553B41A00C340D1 /* SessionProtocolKit */ = { - isa = PBXNativeTarget; - buildConfigurationList = C3C2A86B2553B41A00C340D1 /* Build configuration list for PBXNativeTarget "SessionProtocolKit" */; - buildPhases = ( - 099772F07D67DC2A83009D2F /* [CP] Check Pods Manifest.lock */, - C3C2A85D2553B41A00C340D1 /* Headers */, - C3C2A85E2553B41A00C340D1 /* Sources */, - C3C2A85F2553B41A00C340D1 /* Frameworks */, - C3C2A8602553B41A00C340D1 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = SessionProtocolKit; - productName = SessionProtocolKit; - productReference = C3C2A8622553B41A00C340D1 /* SessionProtocolKit.framework */; - productType = "com.apple.product-type.framework"; - }; D221A088169C9E5E00537ABF /* Session */ = { isa = PBXNativeTarget; buildConfigurationList = D221A0BC169C9E5F00537ABF /* Build configuration list for PBXNativeTarget "Session" */; @@ -4042,7 +3928,6 @@ C3C2A5A5255385C100C340D1 /* PBXTargetDependency */, C3C2A67F255388CC00C340D1 /* PBXTargetDependency */, C3C2A6F625539DE700C340D1 /* PBXTargetDependency */, - C3C2A8682553B41A00C340D1 /* PBXTargetDependency */, C331FF212558F9D300070591 /* PBXTargetDependency */, C33FD9B1255A548A00E217F9 /* PBXTargetDependency */, ); @@ -4117,12 +4002,6 @@ LastSwiftMigration = 1210; ProvisioningStyle = Automatic; }; - C3C2A8612553B41A00C340D1 = { - CreatedOnToolsVersion = 12.1; - DevelopmentTeam = SUQ8J2PCT7; - LastSwiftMigration = 1220; - ProvisioningStyle = Automatic; - }; D221A088169C9E5E00537ABF = { DevelopmentTeam = SUQ8J2PCT7; LastSwiftMigration = 1020; @@ -4187,7 +4066,6 @@ C33FD9AA255A548A00E217F9 /* SignalUtilitiesKit */, C331FF1A2558F9D300070591 /* SessionUIKit */, C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */, - C3C2A8612553B41A00C340D1 /* SessionProtocolKit */, C3C2A59E255385C100C340D1 /* SessionSnodeKit */, C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */, ); @@ -4253,13 +4131,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - C3C2A8602553B41A00C340D1 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D221A087169C9E5E00537ABF /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -4346,28 +4217,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 099772F07D67DC2A83009D2F /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-SessionProtocolKit-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 1460156AE01E0DB0949D61FE /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -4759,7 +4608,6 @@ C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */, B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */, - C32A025A25A7FC55000ED5D4 /* ClosedGroupsV2Migration.swift in Sources */, C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */, C33FDC27255A581F00E217F9 /* YapDatabase+Promise.swift in Sources */, @@ -4967,6 +4815,7 @@ C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, + B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, @@ -5002,27 +4851,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - C3C2A85E2553B41A00C340D1 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B8CA010125A293260091AF73 /* ClosedGroupSenderKey.swift in Sources */, - B8CA011525A293800091AF73 /* Configuration.swift in Sources */, - B8CA011F25A2939F0091AF73 /* SharedSenderKeys.swift in Sources */, - B8CA010B25A293530091AF73 /* ClosedGroupRatchet.swift in Sources */, - B8CA014125A293EE0091AF73 /* Storage.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D221A085169C9E5E00537ABF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewPrivateChatVC.swift in Sources */, - 34D1F0BD1F8D108C0066283D /* AttachmentUploadView.m in Sources */, 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, - 34DBF007206C3CB200025978 /* OWSBubbleShapeView.m in Sources */, - 4C04392A220A9EC800BAEA63 /* VoiceNoteLock.swift in Sources */, 3496956E21A301A100DCFE74 /* OWSBackupExportJob.m in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, @@ -5037,19 +4872,18 @@ 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */, 451A13B11E13DED2000A50FD /* AppNotifications.swift in Sources */, 34D99CE4217509C2000AFB39 /* AppEnvironment.swift in Sources */, - 348570A820F67575004FF32B /* OWSMessageHeaderView.m in Sources */, + C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */, 450DF2091E0DD2C6003D14BE /* UserNotificationsAdaptee.swift in Sources */, - 34B6A907218B5241007C4606 /* TypingIndicatorCell.swift in Sources */, C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */, B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */, - 343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */, B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */, - 34D1F0A91F867BFC0066283D /* ConversationViewCell.m in Sources */, EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */, 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */, - 34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */, + B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */, + B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, + C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */, @@ -5059,24 +4893,26 @@ B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, 3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */, 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, - 34A8B3512190A40E00218A25 /* MediaAlbumCellView.swift in Sources */, - 34D1F0AE1F867BFC0066283D /* OWSMessageCell.m in Sources */, + 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, - 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */, B85A68B12587141A008CC492 /* Storage+Resetting.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, + C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, + B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */, C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, D221A09A169C9E5E00537ABF /* main.m in Sources */, 3496957221A301A100DCFE74 /* OWSBackup.m in Sources */, + B835247925C38D880089A44F /* MessageCell.swift in Sources */, C35D0DA125AE582D00B6BF49 /* MultiDeviceVC.swift in Sources */, B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */, 34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */, 34D1F0521F7E8EA30066283D /* GiphyDownloader.swift in Sources */, 450DF2051E0D74AC003D14BE /* Platform.swift in Sources */, 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, + B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */, B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, 346129991FD1E4DA00532771 /* SignalApp.m in Sources */, 3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */, @@ -5085,39 +4921,39 @@ C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */, C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */, + B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */, 45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */, 45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */, - 34D1F0881F8678AA0066283D /* ConversationViewLayout.m in Sources */, B82B408E239DC00D00A248E7 /* DisplayNameVC.swift in Sources */, + B8214A2B25D63EB9009C0F2A /* MessagesTableView.swift in Sources */, + B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */, + B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */, + C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */, + C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */, 4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */, 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */, - 34D1F0B71F87F8850066283D /* OWSGenericAttachmentView.m in Sources */, - 34D920E720E179C200D51158 /* OWSMessageFooterView.m in Sources */, 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */, 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */, C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */, + B8569AD325CBA13D00DBA3DB /* MediaTextOverlayView.swift in Sources */, 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, - 34B6A905218B4C91007C4606 /* TypingIndicatorInteraction.swift in Sources */, B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */, - 34EA69402194933900702471 /* MediaDownloadView.swift in Sources */, - B8544E3323D50E4900299F14 /* AppearanceUtilities.swift in Sources */, + B8544E3323D50E4900299F14 /* SNAppearance.swift in Sources */, 4C586926224FAB83003FD070 /* AVAudioSession+OWS.m in Sources */, - 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */, C331FFF42558FF0300070591 /* PNOptionView.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */, + B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, - B8B26C8F234D629C004ED98C /* MentionCandidateSelectionView.swift in Sources */, - B8CCF63F23975CFB0091D419 /* JoinPublicChatVC.swift in Sources */, + B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */, - 34DBF003206BD5A500025978 /* OWSMessageTextView.m in Sources */, - 34D1F0B41F86D31D0066283D /* ConversationCollectionView.m in Sources */, B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, + B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, 4CFE6B6C21F92BA700006701 /* LegacyNotificationsAdaptee.swift in Sources */, B8CCF6432397711F0091D419 /* SettingsVC.swift in Sources */, C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */, @@ -5125,35 +4961,30 @@ 3441FD9F21A3604F00BB9542 /* BackupRestoreViewController.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, + B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */, 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, - B82B4094239DF15900A248E7 /* ConversationTitleView.swift in Sources */, 34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */, 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */, + C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */, 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, - 34277A5E20751BDC006049F2 /* OWSQuotedMessageView.m in Sources */, + C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */, - 3488F9362191CC4000E524CC /* ConversationMediaView.swift in Sources */, - 45F32C242057297A00A300D5 /* MessageDetailViewController.swift in Sources */, + 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, + B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, B8A14D702589CE9000E70D57 /* KeyPairMigrationSuccessSheet.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, - 34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */, - 457F671B20746193000EABCD /* QuotedReplyPreview.swift in Sources */, C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */, 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, - 34DBF004206BD5A500025978 /* OWSBubbleView.m in Sources */, 3496957021A301A100DCFE74 /* OWSBackupIO.m in Sources */, - 34AC0A23211C829F00997B47 /* OWSLabel.m in Sources */, - 34EA69422194DE8000702471 /* MediaUploadView.swift in Sources */, + B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, + B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */, 76EB054018170B33006006FC /* AppDelegate.m in Sources */, - 34D1F0831F8678AA0066283D /* ConversationInputTextView.m in Sources */, 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */, - 4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */, - 340FC8B5204DAC8D007AEB0F /* AboutTableViewController.m in Sources */, C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */, C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */, B8BB82A5238F627000BA5194 /* HomeVC.swift in Sources */, @@ -5161,23 +4992,28 @@ C31D1DE9252172D4005D4DA8 /* ContactUtilities.swift in Sources */, 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */, + B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */, B88847BC23E10BC6009836D2 /* GroupMembersVC.swift in Sources */, B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */, B83786802586D296003CE78E /* KeyPairMigrationSheet.swift in Sources */, + B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */, C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */, + B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */, 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */, - 340FC8B0204DAC8D007AEB0F /* AddToBlockListViewController.m in Sources */, 3496957321A301A100DCFE74 /* OWSBackupJob.m in Sources */, B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, + B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, - 34D1F0B01F867BFC0066283D /* OWSSystemMessageCell.m in Sources */, + C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, + C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */, - 34D1F0861F8678AA0066283D /* ConversationViewController.m in Sources */, + C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */, B90418E6183E9DD40038554A /* DateUtil.m in Sources */, C33100092558FF6D00070591 /* UserCell.swift in Sources */, - C3645350252449260045C478 /* VoiceMessageView.swift in Sources */, + B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, + C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */, 3496956F21A301A100DCFE74 /* OWSBackupLazyRestore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5200,11 +5036,6 @@ target = C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */; targetProxy = B8D64FB325BA78270029CFC0 /* PBXContainerItemProxy */; }; - B8D64FB625BA78270029CFC0 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C3C2A8612553B41A00C340D1 /* SessionProtocolKit */; - targetProxy = B8D64FB525BA78270029CFC0 /* PBXContainerItemProxy */; - }; B8D64FB825BA78270029CFC0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C3C2A59E255385C100C340D1 /* SessionSnodeKit */; @@ -5220,11 +5051,6 @@ target = C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */; targetProxy = B8D64FBF25BA784A0029CFC0 /* PBXContainerItemProxy */; }; - B8D64FC225BA784A0029CFC0 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C3C2A8612553B41A00C340D1 /* SessionProtocolKit */; - targetProxy = B8D64FC125BA784A0029CFC0 /* PBXContainerItemProxy */; - }; B8D64FC425BA784A0029CFC0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C3C2A59E255385C100C340D1 /* SessionSnodeKit */; @@ -5260,11 +5086,6 @@ target = C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */; targetProxy = C3C2A6F525539DE700C340D1 /* PBXContainerItemProxy */; }; - C3C2A8682553B41A00C340D1 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C3C2A8612553B41A00C340D1 /* SessionProtocolKit */; - targetProxy = C3C2A8672553B41A00C340D1 /* PBXContainerItemProxy */; - }; C3D90A5525773A1A002C9DF5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C331FF1A2558F9D300070591 /* SessionUIKit */; @@ -5336,7 +5157,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 177; + CURRENT_PROJECT_VERSION = 183; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -5357,7 +5178,7 @@ INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MARKETING_VERSION = 1.7.8; + MARKETING_VERSION = 1.8.0; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5405,7 +5226,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 177; + CURRENT_PROJECT_VERSION = 183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -5431,7 +5252,7 @@ INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MARKETING_VERSION = 1.7.8; + MARKETING_VERSION = 1.8.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5466,7 +5287,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 177; + CURRENT_PROJECT_VERSION = 183; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -5485,7 +5306,7 @@ INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MARKETING_VERSION = 1.7.8; + MARKETING_VERSION = 1.8.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -5536,7 +5357,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 177; + CURRENT_PROJECT_VERSION = 183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -5560,7 +5381,7 @@ INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MARKETING_VERSION = 1.7.8; + MARKETING_VERSION = 1.8.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6257,136 +6078,6 @@ }; name = "App Store Release"; }; - C3C2A86C2553B41A00C340D1 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = AEA8083C060FF9BAFF6E0C9F /* Pods-SessionProtocolKit.debug.xcconfig */; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = SUQ8J2PCT7; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = SessionProtocolKit/Meta/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionProtocolKit"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SUPPORTS_MACCATALYST = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - C3C2A86D2553B41A00C340D1 /* App Store Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 174BD0AE74771D02DAC2B7A9 /* Pods-SessionProtocolKit.app store release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = SUQ8J2PCT7; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INFOPLIST_FILE = SessionProtocolKit/Meta/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionProtocolKit"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SUPPORTS_MACCATALYST = NO; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = "App Store Release"; - }; D221A0BA169C9E5F00537ABF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -6551,7 +6242,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 177; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6587,7 +6278,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 1.7.8; + MARKETING_VERSION = 1.8.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -6619,7 +6310,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 177; + CURRENT_PROJECT_VERSION = 183; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6655,7 +6346,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 1.7.8; + MARKETING_VERSION = 1.8.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; @@ -6736,15 +6427,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = "App Store Release"; }; - C3C2A86B2553B41A00C340D1 /* Build configuration list for PBXNativeTarget "SessionProtocolKit" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - C3C2A86C2553B41A00C340D1 /* Debug */, - C3C2A86D2553B41A00C340D1 /* App Store Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = "App Store Release"; - }; D221A083169C9E5E00537ABF /* Build configuration list for PBXProject "Session" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Session/Basic Chats/NewPrivateChatVC.swift b/Session/Basic Chats/NewPrivateChatVC.swift index 4f8dcfabc..3e5e8c96c 100644 --- a/Session/Basic Chats/NewPrivateChatVC.swift +++ b/Session/Basic Chats/NewPrivateChatVC.swift @@ -183,7 +183,7 @@ private final class EnterPublicKeyVC : UIViewController { view.backgroundColor = .clear // Explanation label let explanationLabel = UILabel() - explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) explanationLabel.font = .systemFont(ofSize: Values.verySmallFontSize) explanationLabel.text = NSLocalizedString("vc_enter_public_key_explanation", comment: "") explanationLabel.numberOfLines = 0 diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 31e476c85..883f48ade 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -100,7 +100,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.members).isEmpty if (!hasContactsToAdd) { addMembersButton.isUserInteractionEnabled = false - let disabledColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addMembersButton.layer.borderColor = disabledColor.cgColor addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal) } @@ -238,7 +238,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega self.members = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) } let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.members).isEmpty self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd - let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity) self.addMembersButton.layer.borderColor = color.cgColor self.addMembersButton.setTitleColor(color, for: UIControl.State.normal) } @@ -247,7 +247,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega private func commitChanges() { let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in - if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationViewController }) { + if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) { editVC.navigationController!.popToViewController(conversationVC, animated: true) } else { editVC.navigationController!.popViewController(animated: true) diff --git a/Session/Closed Groups/GroupMembersVC.swift b/Session/Closed Groups/GroupMembersVC.swift index 9f0542c03..cb279dfbb 100644 --- a/Session/Closed Groups/GroupMembersVC.swift +++ b/Session/Closed Groups/GroupMembersVC.swift @@ -38,7 +38,7 @@ final class GroupMembersVC : BaseVC, UITableViewDataSource { setNavBarTitle("Group Members") // Set up explanation label let explanationLabel = UILabel() - explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.text = "The ability to add members to a closed group is coming soon." explanationLabel.numberOfLines = 0 diff --git a/Session/Conversations/AddToBlockListViewController.h b/Session/Conversations/AddToBlockListViewController.h deleted file mode 100644 index e48943c08..000000000 --- a/Session/Conversations/AddToBlockListViewController.h +++ /dev/null @@ -1,9 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "SelectRecipientViewController.h" - -@interface AddToBlockListViewController : SelectRecipientViewController - -@end diff --git a/Session/Conversations/AddToBlockListViewController.m b/Session/Conversations/AddToBlockListViewController.m deleted file mode 100644 index 74c5cd8db..000000000 --- a/Session/Conversations/AddToBlockListViewController.m +++ /dev/null @@ -1,110 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "AddToBlockListViewController.h" -#import "BlockListUIUtils.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface AddToBlockListViewController () - -@end - -#pragma mark - - -@implementation AddToBlockListViewController - -- (void)loadView -{ - self.delegate = self; - - [super loadView]; - - self.title = NSLocalizedString(@"SETTINGS_ADD_TO_BLOCK_LIST_TITLE", @"Title for the 'add to block list' view."); -} - -- (NSString *)phoneNumberSectionTitle -{ - return NSLocalizedString(@"SETTINGS_ADD_TO_BLOCK_LIST_BLOCK_PHONE_NUMBER_TITLE", - @"Title for the 'block phone number' section of the 'add to block list' view."); -} - -- (NSString *)phoneNumberButtonText -{ - return NSLocalizedString(@"BLOCK_LIST_VIEW_BLOCK_BUTTON", @"A label for the block button in the block list view"); -} - -- (NSString *)contactsSectionTitle -{ - return NSLocalizedString(@"SETTINGS_ADD_TO_BLOCK_LIST_BLOCK_CONTACT_TITLE", - @"Title for the 'block contact' section of the 'add to block list' view."); -} - -- (void)phoneNumberWasSelected:(NSString *)phoneNumber -{ - OWSAssertDebug(phoneNumber.length > 0); - - __weak AddToBlockListViewController *weakSelf = self; - [BlockListUIUtils showBlockPhoneNumberActionSheet:phoneNumber - fromViewController:self - blockingManager:SSKEnvironment.shared.blockingManager - completionBlock:^(BOOL isBlocked) { - if (isBlocked) { - [weakSelf.navigationController popViewControllerAnimated:YES]; - } - }]; -} - -- (BOOL)canSignalAccountBeSelected:(SignalAccount *)signalAccount -{ - OWSAssertDebug(signalAccount); - - return ![SSKEnvironment.shared.blockingManager isRecipientIdBlocked:signalAccount.recipientId]; -} - -- (void)signalAccountWasSelected:(SignalAccount *)signalAccount -{ - OWSAssertDebug(signalAccount); - - __weak AddToBlockListViewController *weakSelf = self; - if ([SSKEnvironment.shared.blockingManager isRecipientIdBlocked:signalAccount.recipientId]) { - OWSFailDebug(@"Cannot add already blocked user to block list."); - return; - } - [BlockListUIUtils showBlockSignalAccountActionSheet:signalAccount - fromViewController:self - blockingManager:SSKEnvironment.shared.blockingManager - completionBlock:^(BOOL isBlocked) { - if (isBlocked) { - [weakSelf.navigationController popViewControllerAnimated:YES]; - } - }]; -} - -- (BOOL)shouldHideLocalNumber -{ - return YES; -} - -- (BOOL)shouldHideContacts -{ - return NO; -} - -- (BOOL)shouldValidatePhoneNumbers -{ - return NO; -} - -- (nullable NSString *)accessoryMessageForSignalAccount:(SignalAccount *)signalAccount -{ - return nil; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift new file mode 100644 index 000000000..901e5e50c --- /dev/null +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -0,0 +1,82 @@ + +extension ContextMenuVC { + + struct Action { + let icon: UIImage + let title: String + let work: () -> Void + + static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action { + let title = "Reply" + return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate.reply(viewItem) } + } + + static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action { + let title = "Copy" + return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate.copy(viewItem) } + } + + static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action { + let title = "Copy Session ID" + return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate.copySessionID(viewItem) } + } + + static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action { + let title = "Delete" + return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate.delete(viewItem) } + } + + static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action { + let title = "Save" + return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate.save(viewItem) } + } + + static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action { + let title = "Ban User" + return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate.ban(viewItem) } + } + } + + static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate) -> [Action] { + func isReplyingAllowed() -> Bool { + guard let message = viewItem.interaction as? TSOutgoingMessage else { return true } + switch message.messageState { + case .failed, .sending: return false + default: return true + } + } + switch viewItem.messageCellType { + case .textOnlyMessage: + var result: [Action] = [] + if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) } + result.append(Action.copy(viewItem, delegate)) + let isGroup = viewItem.isGroupThread + if isGroup && viewItem.interaction is TSIncomingMessage { result.append(Action.copySessionID(viewItem, delegate)) } + if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) } + if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission { result.append(Action.ban(viewItem, delegate)) } + return result + case .mediaMessage, .audio, .genericAttachment: + var result: [Action] = [] + if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) } + if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) } + if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) } + let isGroup = viewItem.isGroupThread + if isGroup && viewItem.interaction is TSIncomingMessage { result.append(Action.copySessionID(viewItem, delegate)) } + if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) } + if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission { result.append(Action.ban(viewItem, delegate)) } + return result + default: return [] + } + } +} + +// MARK: Delegate +protocol ContextMenuActionDelegate { + + func reply(_ viewItem: ConversationViewItem) + func copy(_ viewItem: ConversationViewItem) + func copySessionID(_ viewItem: ConversationViewItem) + func delete(_ viewItem: ConversationViewItem) + func save(_ viewItem: ConversationViewItem) + func ban(_ viewItem: ConversationViewItem) +} diff --git a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift new file mode 100644 index 000000000..0f0e99ffc --- /dev/null +++ b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift @@ -0,0 +1,62 @@ + +extension ContextMenuVC { + + final class ActionView : UIView { + private let action: Action + private let dismiss: () -> Void + + // MARK: Settings + private static let iconSize: CGFloat = 16 + private static let iconImageViewSize: CGFloat = 24 + + // MARK: Lifecycle + init(for action: Action, dismiss: @escaping () -> Void) { + self.action = action + self.dismiss = dismiss + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(for:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(for:) instead.") + } + + private func setUpViewHierarchy() { + // Icon + let iconSize = ActionView.iconSize + let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withTint(Colors.text)) + let iconImageViewSize = ActionView.iconImageViewSize + iconImageView.set(.width, to: iconImageViewSize) + iconImageView.set(.height, to: iconImageViewSize) + iconImageView.contentMode = .center + // Title + let titleLabel = UILabel() + titleLabel.text = action.title + titleLabel.textColor = Colors.text + titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) + // Stack view + let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ]) + stackView.axis = .horizontal + stackView.spacing = Values.smallSpacing + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + let smallSpacing = Values.smallSpacing + stackView.layoutMargins = UIEdgeInsets(top: smallSpacing, leading: smallSpacing, bottom: smallSpacing, trailing: Values.mediumSpacing) + addSubview(stackView) + stackView.pin(to: self) + // Tap gesture recognizer + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tapGestureRecognizer) + } + + // MARK: Interaction + @objc private func handleTap() { + action.work() + dismiss() + } + } +} diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift new file mode 100644 index 000000000..d95747d98 --- /dev/null +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -0,0 +1,135 @@ + +final class ContextMenuVC : UIViewController { + private let snapshot: UIView + private let viewItem: ConversationViewItem + private let frame: CGRect + private let delegate: ContextMenuActionDelegate + private let dismiss: () -> Void + + // MARK: UI Components + private lazy var blurView = UIVisualEffectView(effect: nil) + + private lazy var menuView: UIView = { + let result = UIView() + result.layer.shadowColor = UIColor.black.cgColor + result.layer.shadowOffset = CGSize.zero + result.layer.shadowOpacity = 0.4 + result.layer.shadowRadius = 4 + return result + }() + + private lazy var timestampLabel: UILabel = { + let result = UILabel() + result.text = DateUtil.formatTimestamp(asTime: viewItem.interaction.timestampForUI()) + result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.textColor = isLightMode ? .black : .white + return result + }() + + // MARK: Settings + private static let actionViewHeight: CGFloat = 40 + + // MARK: Lifecycle + init(snapshot: UIView, viewItem: ConversationViewItem, frame: CGRect, delegate: ContextMenuActionDelegate, dismiss: @escaping () -> Void) { + self.snapshot = snapshot + self.viewItem = viewItem + self.frame = frame + self.delegate = delegate + self.dismiss = dismiss + super.init(nibName: nil, bundle: nil) + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(snapshot:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(coder:) instead.") + } + + override func viewDidLoad() { + super.viewDidLoad() + // Background color + view.backgroundColor = .clear + // Blur + view.addSubview(blurView) + blurView.pin(to: view) + // Snapshot + snapshot.layer.shadowColor = UIColor.black.cgColor + snapshot.layer.shadowOffset = CGSize.zero + snapshot.layer.shadowOpacity = 0.4 + snapshot.layer.shadowRadius = 4 + view.addSubview(snapshot) + snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x) + snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y) + snapshot.set(.width, to: frame.width) + snapshot.set(.height, to: frame.height) + // Timestamp + view.addSubview(timestampLabel) + timestampLabel.center(.vertical, in: snapshot) + let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage) + if isOutgoing { + timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) + } else { + timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) + } + // Menu + let menuBackgroundView = UIView() + menuBackgroundView.backgroundColor = Colors.receivedMessageBackground + menuBackgroundView.layer.cornerRadius = Values.messageBubbleCornerRadius + menuBackgroundView.layer.masksToBounds = true + menuView.addSubview(menuBackgroundView) + menuBackgroundView.pin(to: menuView) + let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) } + let menuStackView = UIStackView(arrangedSubviews: actionViews) + menuStackView.axis = .vertical + menuView.addSubview(menuStackView) + menuStackView.pin(to: menuView) + view.addSubview(menuView) + let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight + let spacing = Values.smallSpacing + let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing) + if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin { + menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing) + } else { + menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing) + } + switch viewItem.interaction.interactionType() { + case .outgoingMessage: menuView.pin(.right, to: .right, of: snapshot) + case .incomingMessage: menuView.pin(.left, to: .left, of: snapshot) + default: break // Should never occur + } + // Tap gesture + let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + view.addGestureRecognizer(mainTapGestureRecognizer) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIView.animate(withDuration: 0.25) { + self.blurView.effect = UIBlurEffect(style: .regular) + self.menuView.alpha = 1 + } + } + + // MARK: Updating + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: Values.messageBubbleCornerRadius).cgPath + } + + // MARK: Interaction + @objc private func handleTap() { + snDismiss() + } + + func snDismiss() { + UIView.animate(withDuration: 0.25, animations: { + self.blurView.effect = nil + self.menuView.alpha = 0 + self.timestampLabel.alpha = 0 + }, completion: { _ in + self.dismiss() + }) + } +} diff --git a/Session/Conversations/Context Menu/ContextMenuWindow.swift b/Session/Conversations/Context Menu/ContextMenuWindow.swift new file mode 100644 index 000000000..9cd7fe4ff --- /dev/null +++ b/Session/Conversations/Context Menu/ContextMenuWindow.swift @@ -0,0 +1,28 @@ + +final class ContextMenuWindow : UIWindow { + + override var windowLevel: UIWindow.Level { + get { return UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude - 1) } + set { /* Do nothing */ } + } + + override init(frame: CGRect) { + super.init(frame: frame) + initialize() + } + + @available(iOS 13.0, *) + override init(windowScene: UIWindowScene) { + super.init(windowScene: windowScene) + initialize() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initialize() + } + + private func initialize() { + backgroundColor = .clear + } +} diff --git a/Session/Conversations/ConversationCollectionView.h b/Session/Conversations/ConversationCollectionView.h deleted file mode 100644 index 419c4574e..000000000 --- a/Session/Conversations/ConversationCollectionView.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@protocol ConversationCollectionViewDelegate - -- (void)collectionViewWillChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize; -- (void)collectionViewDidChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize; - -@end - -#pragma mark - - -@interface ConversationCollectionView : UICollectionView - -@property (weak, nonatomic) id layoutDelegate; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationCollectionView.m b/Session/Conversations/ConversationCollectionView.m deleted file mode 100644 index a6aad672f..000000000 --- a/Session/Conversations/ConversationCollectionView.m +++ /dev/null @@ -1,90 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationCollectionView.h" - -NS_ASSUME_NONNULL_BEGIN - -#pragma mark - - -@implementation ConversationCollectionView - -- (void)setFrame:(CGRect)frame -{ - if (frame.size.width == 0 || frame.size.height == 0) { - // Ignore iOS Auto Layout's tendency to temporarily zero out the - // frame of this view during the layout process. - // - // The conversation view has an invariant that the collection view - // should always have a "reasonable" (correct width, non-zero height) - // size. This lets us manipulate scroll state at all times, especially - // before the view has been presented for the first time. This - // invariant also saves us from needing all sorts of ugly and incomplete - // hacks in the conversation view's code. - return; - } - CGSize oldSize = self.frame.size; - CGSize newSize = frame.size; - BOOL isChanging = !CGSizeEqualToSize(oldSize, newSize); - if (isChanging) { - [self.layoutDelegate collectionViewWillChangeSizeFrom:oldSize to:newSize]; - } - [super setFrame:frame]; - if (isChanging) { - [self.layoutDelegate collectionViewDidChangeSizeFrom:oldSize to:newSize]; - } -} - -- (void)setBounds:(CGRect)bounds -{ - if (bounds.size.width == 0 || bounds.size.height == 0) { - // Ignore iOS Auto Layout's tendency to temporarily zero out the - // frame of this view during the layout process. - // - // The conversation view has an invariant that the collection view - // should always have a "reasonable" (correct width, non-zero height) - // size. This lets us manipulate scroll state at all times, especially - // before the view has been presented for the first time. This - // invariant also saves us from needing all sorts of ugly and incomplete - // hacks in the conversation view's code. - return; - } - CGSize oldSize = self.bounds.size; - CGSize newSize = bounds.size; - BOOL isChanging = !CGSizeEqualToSize(oldSize, newSize); - if (isChanging) { - [self.layoutDelegate collectionViewWillChangeSizeFrom:oldSize to:newSize]; - } - [super setBounds:bounds]; - if (isChanging) { - [self.layoutDelegate collectionViewDidChangeSizeFrom:oldSize to:newSize]; - } -} - -- (void)setContentOffset:(CGPoint)contentOffset -{ - if (self.contentSize.height < 1 && CGPointEqualToPoint(CGPointZero, contentOffset)) { - // [UIScrollView _adjustContentOffsetIfNecessary] resets the content - // offset to zero under a number of undocumented conditions. We don't - // want this behavior; we want fine-grained control over the default - // scroll state of the message view. - // - // [UIScrollView _adjustContentOffsetIfNecessary] is called in - // response to many different events; trying to prevent them all is - // whack-a-mole. - // - // It's not safe to override [UIScrollView _adjustContentOffsetIfNecessary], - // since its a private API. - // - // We can avoid the issue by simply ignoring attempt to reset the content - // offset to zero before the collection view has determined its content size. - return; - } - - [super setContentOffset:contentOffset]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationInputTextView.h b/Session/Conversations/ConversationInputTextView.h deleted file mode 100644 index 033eddce3..000000000 --- a/Session/Conversations/ConversationInputTextView.h +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class SignalAttachment; - -@protocol ConversationInputTextViewDelegate - -- (void)didPasteAttachment:(SignalAttachment *_Nullable)attachment; - -- (void)inputTextViewSendMessagePressed; - -- (void)textViewDidChange:(UITextView *)textView; - -@end - -#pragma mark - - -@protocol ConversationTextViewToolbarDelegate - -- (void)textViewDidChange:(UITextView *)textView; -- (void)textViewDidChangeSelection:(UITextView *)textView; - -@end - -#pragma mark - - -@interface ConversationInputTextView : OWSTextView - -@property (weak, nonatomic) id inputTextViewDelegate; - -@property (weak, nonatomic) id textViewToolbarDelegate; - -- (NSString *)trimmedText; -- (void)setPlaceholderText:(NSString *)placeholderText; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationInputTextView.m b/Session/Conversations/ConversationInputTextView.m deleted file mode 100644 index 1a7e86721..000000000 --- a/Session/Conversations/ConversationInputTextView.m +++ /dev/null @@ -1,248 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationInputTextView.h" -#import "Session-Swift.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ConversationInputTextView () - -@property (nonatomic) UILabel *placeholderView; -@property (nonatomic) NSArray *placeholderConstraints; - -@end - -#pragma mark - - -@implementation ConversationInputTextView - -- (instancetype)init -{ - self = [super init]; - if (self) { - [self setTranslatesAutoresizingMaskIntoConstraints:NO]; - - self.delegate = self; - self.backgroundColor = nil; - - self.showsHorizontalScrollIndicator = NO; - self.showsVerticalScrollIndicator = NO; - - self.scrollEnabled = YES; - self.scrollsToTop = NO; - self.userInteractionEnabled = YES; - - self.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - self.textColor = LKColors.text; - self.textAlignment = NSTextAlignmentNatural; - self.tintColor = LKColors.accent; - - self.contentMode = UIViewContentModeRedraw; - self.dataDetectorTypes = UIDataDetectorTypeNone; - - self.text = nil; - - self.placeholderView = [UILabel new]; - self.placeholderView.text = NSLocalizedString(@"Message", @""); - self.placeholderView.textColor = [LKColors.text colorWithAlphaComponent:LKValues.composeViewTextFieldPlaceholderOpacity]; - self.placeholderView.userInteractionEnabled = NO; - [self addSubview:self.placeholderView]; - - // We need to do these steps _after_ placeholderView is configured. - self.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - CGFloat hMarginLeading = 16.f; - CGFloat hMarginTrailing = 16.f; - self.textContainerInset = UIEdgeInsetsMake(11.f, - CurrentAppContext().isRTL ? hMarginTrailing : hMarginLeading, - 11.f, - CurrentAppContext().isRTL ? hMarginLeading : hMarginTrailing); - self.textContainer.lineFragmentPadding = 0; - self.contentInset = UIEdgeInsetsZero; - - [self ensurePlaceholderConstraints]; - [self updatePlaceholderVisibility]; - } - - return self; -} - -#pragma mark - - -- (void)setFont:(UIFont *_Nullable)font -{ - [super setFont:font]; - - self.placeholderView.font = font; -} - -- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)isAnimated -{ - // When creating new lines, contentOffset is animated, but because because - // we are simultaneously resizing the text view, this can cause the - // text in the textview to be "too high" in the text view. - // Solution is to disable animation for setting content offset. - [super setContentOffset:contentOffset animated:NO]; -} - -- (void)setContentInset:(UIEdgeInsets)contentInset -{ - [super setContentInset:contentInset]; - - [self ensurePlaceholderConstraints]; -} - -- (void)setTextContainerInset:(UIEdgeInsets)textContainerInset -{ - [super setTextContainerInset:textContainerInset]; - - [self ensurePlaceholderConstraints]; -} - -- (void)ensurePlaceholderConstraints -{ - OWSAssertDebug(self.placeholderView); - - if (self.placeholderConstraints) { - [NSLayoutConstraint deactivateConstraints:self.placeholderConstraints]; - } - - // We align the location of our placeholder with the text content of - // this view. The only safe way to do that is by measuring the - // beginning position. - UITextRange *beginningTextRange = - [self textRangeFromPosition:self.beginningOfDocument toPosition:self.beginningOfDocument]; - CGRect beginningTextRect = [self firstRectForRange:beginningTextRange]; - - CGFloat topInset = beginningTextRect.origin.y; - CGFloat leftInset = beginningTextRect.origin.x; - - // we use Left instead of Leading, since it's based on the prior CGRect offset - self.placeholderConstraints = @[ - [self.placeholderView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:leftInset], - [self.placeholderView autoPinEdgeToSuperviewEdge:ALEdgeRight], - [self.placeholderView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:topInset], - ]; -} - -- (void)updatePlaceholderVisibility -{ - self.placeholderView.hidden = self.text.length > 0; -} - -- (void)setText:(NSString *_Nullable)text -{ - [super setText:text]; - - [self updatePlaceholderVisibility]; -} - -- (BOOL)canBecomeFirstResponder -{ - return YES; -} - -- (BOOL)pasteboardHasPossibleAttachment -{ - // We don't want to load/convert images more than once so we - // only do a cursory validation pass at this time. - return ([SignalAttachment pasteboardHasPossibleAttachment] && ![SignalAttachment pasteboardHasText]); -} - -- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender -{ - if (action == @selector(paste:)) { - if ([self pasteboardHasPossibleAttachment]) { - return YES; - } - } - return [super canPerformAction:action withSender:sender]; -} - -- (void)paste:(nullable id)sender -{ - if ([self pasteboardHasPossibleAttachment]) { - SignalAttachment *attachment = [SignalAttachment attachmentFromPasteboard]; - // Note: attachment might be nil or have an error at this point; that's fine. - [self.inputTextViewDelegate didPasteAttachment:attachment]; - return; - } - - [super paste:sender]; -} - -- (NSString *)trimmedText -{ - return [self.text ows_stripped]; -} - -- (void)setPlaceholderText:(NSString *)placeholderText -{ - [self.placeholderView setText:placeholderText]; -} - -#pragma mark - UITextViewDelegate - -- (void)textViewDidChange:(UITextView *)textView -{ - OWSAssertDebug(self.inputTextViewDelegate); - OWSAssertDebug(self.textViewToolbarDelegate); - - [self updatePlaceholderVisibility]; - - [self.inputTextViewDelegate textViewDidChange:self]; - [self.textViewToolbarDelegate textViewDidChange:self]; -} - -- (void)textViewDidChangeSelection:(UITextView *)textView -{ - [self.textViewToolbarDelegate textViewDidChangeSelection:self]; -} - -#pragma mark - Key Commands - -- (nullable NSArray *)keyCommands -{ - // We're permissive about what modifier key we accept for the "send message" hotkey. - // We accept command-return, option-return. - // - // We don't support control-return because it doesn't work. - // - // We don't support shift-return because it is often used for "newline" in other - // messaging apps. - return @[ - [self keyCommandWithInput:@"\r" - modifierFlags:UIKeyModifierCommand - action:@selector(modifiedReturnPressed:) - discoverabilityTitle:@"Send Message"], - // "Alternate" is option. - [self keyCommandWithInput:@"\r" - modifierFlags:UIKeyModifierAlternate - action:@selector(modifiedReturnPressed:) - discoverabilityTitle:@"Send Message"], - ]; -} - -- (UIKeyCommand *)keyCommandWithInput:(NSString *)input - modifierFlags:(UIKeyModifierFlags)modifierFlags - action:(SEL)action - discoverabilityTitle:(NSString *)discoverabilityTitle -{ - return [UIKeyCommand keyCommandWithInput:input - modifierFlags:modifierFlags - action:action - discoverabilityTitle:discoverabilityTitle]; -} - -- (void)modifiedReturnPressed:(UIKeyCommand *)sender -{ - OWSLogInfo(@"modifiedReturnPressed: %@", sender.input); - [self.inputTextViewDelegate inputTextViewSendMessagePressed]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationInputToolbar.h b/Session/Conversations/ConversationInputToolbar.h deleted file mode 100644 index 15494abad..000000000 --- a/Session/Conversations/ConversationInputToolbar.h +++ /dev/null @@ -1,97 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; -@class LKMention; -@class LKMentionCandidateSelectionView; -@class OWSLinkPreviewDraft; -@class OWSQuotedReplyModel; -@class SignalAttachment; -@class TSThread; - -@protocol ConversationInputToolbarDelegate - -- (void)sendButtonPressed; - -- (void)attachmentButtonPressed; - -#pragma mark - Voice Memo - -- (void)voiceMemoGestureDidStart; - -- (void)voiceMemoGestureDidLock; - -- (void)voiceMemoGestureDidComplete; - -- (void)voiceMemoGestureDidCancel; - -- (void)voiceMemoGestureDidUpdateCancelWithRatioComplete:(CGFloat)cancelAlpha; - -- (void)handleMentionCandidateSelected:(LKMention *)mentionCandidate from:(LKMentionCandidateSelectionView *)mentionCandidateSelectionView; - -@end - -#pragma mark - - -@class ConversationInputTextView; - -@protocol ConversationInputTextViewDelegate; - -@interface ConversationInputToolbar : UIView - -- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle NS_DESIGNATED_INITIALIZER; -- (instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; -- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; - -@property (nonatomic, weak) id inputToolbarDelegate; - -- (void)beginEditingTextMessage; -- (void)endEditingTextMessage; -- (BOOL)isInputTextViewFirstResponder; - -- (void)setInputTextViewDelegate:(id)value; - -- (NSString *)messageText; -- (void)setMessageText:(NSString *_Nullable)value animated:(BOOL)isAnimated; -- (void)setPlaceholderText:(NSString *)placeholderText; -- (void)clearTextMessageAnimated:(BOOL)isAnimated; -- (void)toggleDefaultKeyboard; -- (void)setAttachmentButtonHidden:(BOOL)isHidden; - -- (void)updateFontSizes; - -- (void)updateLayoutWithSafeAreaInsets:(UIEdgeInsets)safeAreaInsets; -- (void)ensureTextViewHeight; - -#pragma mark - Voice Memo - -- (void)lockVoiceMemoUI; - -- (void)showVoiceMemoUI; - -- (void)hideVoiceMemoUI:(BOOL)animated; - -- (void)setVoiceMemoUICancelAlpha:(CGFloat)cancelAlpha; - -- (void)cancelVoiceMemoIfNecessary; - -#pragma mark - - -@property (nonatomic, nullable) OWSQuotedReplyModel *quotedReply; - -@property (nonatomic, nullable, readonly) OWSLinkPreviewDraft *linkPreviewDraft; - -- (void)hideInputMethod; - -#pragma mark - Mention Candidate Selection View - -- (void)showMentionCandidateSelectionViewFor:(NSArray *)mentionCandidates in:(TSThread *)thread; - -- (void)hideMentionCandidateSelectionView; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationInputToolbar.m b/Session/Conversations/ConversationInputToolbar.m deleted file mode 100644 index a397466ec..000000000 --- a/Session/Conversations/ConversationInputToolbar.m +++ /dev/null @@ -1,1126 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationInputToolbar.h" -#import "ConversationInputTextView.h" -#import "Environment.h" -#import "OWSMath.h" -#import "Session-Swift.h" -#import "UIColor+OWS.h" -#import "UIFont+OWS.h" -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_CLOSED_ENUM(NSUInteger, VoiceMemoRecordingState){ - VoiceMemoRecordingState_Idle, - VoiceMemoRecordingState_RecordingHeld, - VoiceMemoRecordingState_RecordingLocked -}; - -static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext; - -const CGFloat kMinTextViewHeight = 40; -const CGFloat kMaxTextViewHeight = 120; - -#pragma mark - - -@interface InputLinkPreview : NSObject - -@property (nonatomic) NSString *previewUrl; -@property (nonatomic, nullable) OWSLinkPreviewDraft *linkPreviewDraft; - -@end - -#pragma mark - - -@implementation InputLinkPreview - -@end - -#pragma mark - - -@interface ConversationInputToolbar () - -@property (nonatomic, readonly) ConversationStyle *conversationStyle; - -@property (nonatomic, readonly) ConversationInputTextView *inputTextView; -@property (nonatomic, readonly) UIStackView *hStack; -@property (nonatomic, readonly) UIButton *attachmentButton; -@property (nonatomic, readonly) UIButton *sendButton; -@property (nonatomic, readonly) UIButton *voiceMemoButton; -@property (nonatomic, readonly) UIView *quotedReplyWrapper; -@property (nonatomic, readonly) UIView *linkPreviewWrapper; -@property (nonatomic, readonly) UIView *borderView; - -@property (nonatomic) CGFloat textViewHeight; -@property (nonatomic, readonly) NSLayoutConstraint *textViewHeightConstraint; - -#pragma mark - Voice Memo Recording UI - -@property (nonatomic, nullable) UIView *voiceMemoUI; -@property (nonatomic, nullable) VoiceMemoLockView *voiceMemoLockView; -@property (nonatomic, nullable) UIView *voiceMemoContentView; -@property (nonatomic) NSDate *voiceMemoStartTime; -@property (nonatomic, nullable) NSTimer *voiceMemoUpdateTimer; -@property (nonatomic) UIGestureRecognizer *voiceMemoGestureRecognizer; -@property (nonatomic, nullable) UILabel *voiceMemoCancelLabel; -@property (nonatomic, nullable) UIView *voiceMemoRedRecordingCircle; -@property (nonatomic, nullable) UILabel *recordingLabel; -@property (nonatomic, readonly) BOOL isRecordingVoiceMemo; -@property (nonatomic) VoiceMemoRecordingState voiceMemoRecordingState; -@property (nonatomic) CGPoint voiceMemoGestureStartLocation; -@property (nonatomic, nullable) NSArray *layoutContraints; -@property (nonatomic) UIEdgeInsets receivedSafeAreaInsets; -@property (nonatomic, nullable) InputLinkPreview *inputLinkPreview; -@property (nonatomic) BOOL wasLinkPreviewCancelled; -@property (nonatomic, nullable, weak) LinkPreviewView *linkPreviewView; -@property (nonatomic) LKMentionCandidateSelectionView *mentionCandidateSelectionView; -@property (nonatomic) NSLayoutConstraint *mentionCandidateSelectionViewSizeConstraint; - -@end - -#pragma mark - - -@implementation ConversationInputToolbar - -- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle -{ - self = [super initWithFrame:CGRectZero]; - - _conversationStyle = conversationStyle; - _receivedSafeAreaInsets = UIEdgeInsetsZero; - - if (self) { - [self createContents]; - } - - return self; -} - -- (CGSize)intrinsicContentSize -{ - // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify - // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. - return CGSizeZero; -} - -- (void)createContents -{ - self.layoutMargins = UIEdgeInsetsZero; - self.autoresizingMask = UIViewAutoresizingFlexibleHeight; - - self.backgroundColor = LKColors.composeViewBackground; - - _inputTextView = [ConversationInputTextView new]; - self.inputTextView.textViewToolbarDelegate = self; - self.inputTextView.textColor = LKColors.text; - self.inputTextView.font = [UIFont systemFontOfSize:LKValues.mediumFontSize]; - self.inputTextView.backgroundColor = LKColors.composeViewTextFieldBackground; - [self.inputTextView setContentHuggingLow]; - [self.inputTextView setCompressionResistanceLow]; - self.inputTextView.accessibilityLabel = @"Input text view"; - self.inputTextView.isAccessibilityElement = YES; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _inputTextView); - - _textViewHeightConstraint = [self.inputTextView autoSetDimension:ALDimensionHeight toSize:kMinTextViewHeight]; - - _attachmentButton = [[UIButton alloc] init]; - self.attachmentButton.accessibilityLabel = NSLocalizedString(@"ATTACHMENT_LABEL", @"Accessibility label for attaching photos"); - self.attachmentButton.accessibilityHint = NSLocalizedString(@"ATTACHMENT_HINT", @"Accessibility hint describing what you can do with the attachment button"); - [self.attachmentButton addTarget:self action:@selector(attachmentButtonPressed) forControlEvents:UIControlEventTouchUpInside]; - UIImage *attachmentImage = [[UIImage imageNamed:@"CirclePlus"] asTintedImageWithColor:LKColors.text]; - [self.attachmentButton setImage:attachmentImage forState:UIControlStateNormal]; - [self.attachmentButton autoSetDimensionsToSize:CGSizeMake(40, kMinTextViewHeight)]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _attachmentButton); - - _sendButton = [UIButton buttonWithType:UIButtonTypeCustom]; - NSString *iconName = LKAppModeUtilities.isLightMode ? @"ArrowUpLightMode" : @"ArrowUpDarkMode"; - UIImage *sendImage = [UIImage imageNamed:iconName]; - [self.sendButton setImage:sendImage forState:UIControlStateNormal]; - [self.sendButton autoSetDimensionsToSize:CGSizeMake(40, kMinTextViewHeight)]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _sendButton); - [self.sendButton addTarget:self action:@selector(sendButtonPressed) forControlEvents:UIControlEventTouchUpInside]; - self.sendButton.accessibilityLabel = @"Send button"; - self.sendButton.isAccessibilityElement = YES; - - _voiceMemoButton = [UIButton buttonWithType:UIButtonTypeCustom]; - UIImage *voiceMemoIcon = [[UIImage imageNamed:@"Microphone"] asTintedImageWithColor:LKColors.text]; - [self.voiceMemoButton setImage:voiceMemoIcon forState:UIControlStateNormal]; - [self.voiceMemoButton autoSetDimensionsToSize:CGSizeMake(40, kMinTextViewHeight)]; - self.voiceMemoButton.accessibilityLabel = @"Voice message button"; - self.voiceMemoButton.isAccessibilityElement = YES; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _voiceMemoButton); - - // We want to be permissive about the voice message gesture, so we hang - // the long press GR on the button's wrapper, not the button itself. - UILongPressGestureRecognizer *longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)]; - longPressGestureRecognizer.minimumPressDuration = 0; - self.voiceMemoGestureRecognizer = longPressGestureRecognizer; - [self.voiceMemoButton addGestureRecognizer:longPressGestureRecognizer]; - - self.userInteractionEnabled = YES; - - _quotedReplyWrapper = [UIView containerView]; - self.quotedReplyWrapper.backgroundColor = LKColors.composeViewTextFieldBackground; - self.quotedReplyWrapper.hidden = YES; - [self.quotedReplyWrapper setContentHuggingHorizontalLow]; - [self.quotedReplyWrapper setCompressionResistanceHorizontalLow]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _quotedReplyWrapper); - - _linkPreviewWrapper = [UIView containerView]; - self.linkPreviewWrapper.hidden = YES; - [self.linkPreviewWrapper setContentHuggingHorizontalLow]; - [self.linkPreviewWrapper setCompressionResistanceHorizontalLow]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _linkPreviewWrapper); - - // V Stack - UIStackView *vStack = [[UIStackView alloc] initWithArrangedSubviews:@[ self.quotedReplyWrapper, self.linkPreviewWrapper, self.inputTextView ]]; - vStack.axis = UILayoutConstraintAxisVertical; - [vStack setContentHuggingHorizontalLow]; - [vStack setCompressionResistanceHorizontalLow]; - - for (UIView *button in @[ self.attachmentButton, self.voiceMemoButton, self.sendButton ]) { - [button setContentHuggingHorizontalHigh]; - [button setCompressionResistanceHorizontalHigh]; - } - - // V Stack Wrapper - const CGFloat vStackRounding = kMinTextViewHeight / 2; - UIView *vStackWrapper = [UIView containerView]; - vStackWrapper.layer.cornerRadius = vStackRounding; - vStackWrapper.clipsToBounds = YES; - [vStackWrapper addSubview:vStack]; - [vStack ows_autoPinToSuperviewEdges]; - [vStackWrapper setContentHuggingHorizontalLow]; - [vStackWrapper setCompressionResistanceHorizontalLow]; - - // User Selection View - _mentionCandidateSelectionView = [LKMentionCandidateSelectionView new]; - [self addSubview:self.mentionCandidateSelectionView]; - [self.mentionCandidateSelectionView autoPinEdgeToSuperviewEdge:ALEdgeTop]; - [self.mentionCandidateSelectionView autoPinWidthToSuperview]; - self.mentionCandidateSelectionViewSizeConstraint = [self.mentionCandidateSelectionView autoSetDimension:ALDimensionHeight toSize:0]; - self.mentionCandidateSelectionView.alpha = 0; - self.mentionCandidateSelectionView.delegate = self; - - // Button Container - UIView *buttonContainer = [UIView new]; - [buttonContainer addSubview:self.voiceMemoButton]; - [self.voiceMemoButton ows_autoPinToSuperviewEdges]; - [buttonContainer addSubview:self.sendButton]; - [self.sendButton ows_autoPinToSuperviewEdges]; - - // H Stack - _hStack = [[UIStackView alloc] - initWithArrangedSubviews:@[ self.attachmentButton, vStackWrapper, buttonContainer ]]; - self.hStack.axis = UILayoutConstraintAxisHorizontal; - self.hStack.layoutMarginsRelativeArrangement = YES; - self.hStack.layoutMargins = UIEdgeInsetsMake(LKValues.smallSpacing, LKValues.smallSpacing, LKValues.smallSpacing, LKValues.smallSpacing); - self.hStack.alignment = UIStackViewAlignmentBottom; - self.hStack.spacing = LKValues.smallSpacing; - - [self addSubview:self.hStack]; - [self.hStack autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.mentionCandidateSelectionView]; - [self.hStack autoPinEdgeToSuperviewSafeArea:ALEdgeBottom]; - [self.hStack setContentHuggingHorizontalLow]; - [self.hStack setCompressionResistanceHorizontalLow]; - - // See comments on updateContentLayout:. - if (@available(iOS 11, *)) { - vStack.insetsLayoutMarginsFromSafeArea = NO; - vStackWrapper.insetsLayoutMarginsFromSafeArea = NO; - self.hStack.insetsLayoutMarginsFromSafeArea = NO; - self.insetsLayoutMarginsFromSafeArea = NO; - } - vStack.preservesSuperviewLayoutMargins = NO; - vStackWrapper.preservesSuperviewLayoutMargins = NO; - self.hStack.preservesSuperviewLayoutMargins = NO; - self.preservesSuperviewLayoutMargins = NO; - - // Border - // - // The border must reside _outside_ of vStackWrapper so - // that it doesn't run afoul of its clipping, so we can't - // use addBorderViewWithColor. - _borderView = [UIView new]; - self.borderView.userInteractionEnabled = NO; - self.borderView.backgroundColor = UIColor.clearColor; - self.borderView.opaque = NO; - self.borderView.layer.borderColor = LKColors.text.CGColor; - self.borderView.layer.opacity = LKValues.composeViewTextFieldBorderOpacity; - self.borderView.layer.borderWidth = LKValues.composeViewTextFieldBorderThickness; - self.borderView.layer.cornerRadius = vStackRounding; - [self addSubview:self.borderView]; - [self.borderView autoPinToEdgesOfView:vStackWrapper]; - [self.borderView setCompressionResistanceLow]; - [self.borderView setContentHuggingLow]; - - [self ensureShouldShowVoiceMemoButtonAnimated:NO doLayout:NO]; -} - -- (void)updateFontSizes -{ - self.inputTextView.font = [UIFont ows_dynamicTypeBodyFont]; -} - -- (void)setInputTextViewDelegate:(id)value -{ - OWSAssertDebug(self.inputTextView); - OWSAssertDebug(value); - - self.inputTextView.inputTextViewDelegate = value; -} - -- (NSString *)messageText -{ - OWSAssertDebug(self.inputTextView); - - return self.inputTextView.trimmedText; -} - -- (void)setMessageText:(NSString *_Nullable)value animated:(BOOL)isAnimated -{ - OWSAssertDebug(self.inputTextView); - - self.inputTextView.text = value; - - // It's important that we set the textViewHeight before - // doing any animation in `ensureShouldShowVoiceMemoButtonAnimated` - // Otherwise, the resultant keyboard frame posted in `keyboardWillChangeFrame` - // could reflect the inputTextView height *before* the new text was set. - // - // This bug was surfaced to the user as: - // - have a quoted reply draft in the input toolbar - // - type a multiline message - // - hit send - // - quoted reply preview and message text is cleared - // - input toolbar is shrunk to it's expected empty-text height - // - *but* the conversation's bottom content inset was too large. Specifically, it was - // still sized as if the input textview was multiple lines. - // Presumably this bug only surfaced when an animation coincides with more complicated layout - // changes (in this case while simultaneous with removing quoted reply subviews, hiding the - // wrapper view *and* changing the height of the input textView - [self ensureTextViewHeight]; - [self updateInputLinkPreview]; - - [self ensureShouldShowVoiceMemoButtonAnimated:isAnimated doLayout:YES]; -} - -- (void)setPlaceholderText:(NSString *)placeholderText -{ - [self.inputTextView setPlaceholderText:placeholderText]; -} - -- (void)ensureTextViewHeight -{ - [self updateHeightWithTextView:self.inputTextView]; -} - -- (void)clearTextMessageAnimated:(BOOL)isAnimated -{ - [self setMessageText:nil animated:isAnimated]; - [self.inputTextView.undoManager removeAllActions]; - self.wasLinkPreviewCancelled = NO; -} - -- (void)toggleDefaultKeyboard -{ - // Primary language is nil for the emoji keyboard. - if (!self.inputTextView.textInputMode.primaryLanguage) { - // Stay on emoji keyboard after sending - return; - } - - // Otherwise, we want to toggle back to default keyboard if the user had the numeric keyboard present. - - // Momentarily switch to a non-default keyboard, else reloadInputViews - // will not affect the displayed keyboard. In practice this isn't perceptable to the user. - // The alternative would be to dismiss-and-pop the keyboard, but that can cause a more pronounced animation. - self.inputTextView.keyboardType = UIKeyboardTypeNumbersAndPunctuation; - [self.inputTextView reloadInputViews]; - - self.inputTextView.keyboardType = UIKeyboardTypeDefault; - [self.inputTextView reloadInputViews]; -} - -- (void)setAttachmentButtonHidden:(BOOL)isHidden -{ - [self.attachmentButton setHidden:isHidden]; -} - -- (void)setQuotedReply:(nullable OWSQuotedReplyModel *)quotedReply -{ - if (quotedReply == _quotedReply) { - return; - } - - [self clearQuotedMessagePreview]; - - _quotedReply = quotedReply; - - if (!quotedReply) { - return; - } - - QuotedReplyPreview *quotedMessagePreview = - [[QuotedReplyPreview alloc] initWithQuotedReply:quotedReply conversationStyle:self.conversationStyle]; - quotedMessagePreview.delegate = self; - [quotedMessagePreview setContentHuggingHorizontalLow]; - [quotedMessagePreview setCompressionResistanceHorizontalLow]; - - self.quotedReplyWrapper.hidden = NO; - self.quotedReplyWrapper.layoutMargins = UIEdgeInsetsZero; - [self.quotedReplyWrapper addSubview:quotedMessagePreview]; - [quotedMessagePreview ows_autoPinToSuperviewMargins]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, quotedMessagePreview); - - self.linkPreviewView.hasAsymmetricalRounding = !self.quotedReply; -} - -- (CGFloat)quotedMessageTopMargin -{ - return 5.f; -} - -- (void)clearQuotedMessagePreview -{ - self.quotedReplyWrapper.hidden = YES; - for (UIView *subview in self.quotedReplyWrapper.subviews) { - [subview removeFromSuperview]; - } -} - -- (void)beginEditingTextMessage -{ - [self.inputTextView becomeFirstResponder]; -} - -- (void)endEditingTextMessage -{ - [self.inputTextView resignFirstResponder]; -} - -- (BOOL)isInputTextViewFirstResponder -{ - return self.inputTextView.isFirstResponder; -} - -- (void)ensureShouldShowVoiceMemoButtonAnimated:(BOOL)isAnimated doLayout:(BOOL)doLayout -{ - void (^updateBlock)(void) = ^{ - if (self.inputTextView.trimmedText.length > 0) { - if (self.voiceMemoButton.alpha != 0) { - self.voiceMemoButton.alpha = 0; - } - - if (self.sendButton.alpha == 0) { - self.sendButton.alpha = 1; - } - } else { - if (self.voiceMemoButton.alpha == 0) { - self.voiceMemoButton.alpha = 1; - } - - if (self.sendButton.alpha != 0) { - self.sendButton.alpha = 0; - } - } - if (doLayout) { - [self layoutIfNeeded]; - } - }; - - if (isAnimated) { - [UIView animateWithDuration:0.1 animations:updateBlock]; - } else { - updateBlock(); - } -} - -// iOS doesn't always update the safeAreaInsets correctly & in a timely -// way for the inputAccessoryView after a orientation change. The best -// workaround appears to be to use the safeAreaInsets from -// ConversationViewController's view. ConversationViewController updates -// this input toolbar using updateLayoutWithIsLandscape:. -- (void)updateContentLayout -{ - if (self.layoutContraints) { - [NSLayoutConstraint deactivateConstraints:self.layoutContraints]; - } - - self.layoutContraints = @[ - [self.hStack autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:self.receivedSafeAreaInsets.left], - [self.hStack autoPinEdgeToSuperviewEdge:ALEdgeRight withInset:self.receivedSafeAreaInsets.right], - ]; -} - -- (void)updateLayoutWithSafeAreaInsets:(UIEdgeInsets)safeAreaInsets -{ - BOOL didChange = !UIEdgeInsetsEqualToEdgeInsets(self.receivedSafeAreaInsets, safeAreaInsets); - BOOL hasLayout = self.layoutContraints != nil; - - self.receivedSafeAreaInsets = safeAreaInsets; - - if (didChange || !hasLayout) { - [self updateContentLayout]; - } -} - -- (void)handleLongPress:(UIGestureRecognizer *)sender -{ - switch (sender.state) { - case UIGestureRecognizerStatePossible: - case UIGestureRecognizerStateCancelled: - case UIGestureRecognizerStateFailed: - if (self.isRecordingVoiceMemo) { - // Cancel voice message if necessary. - self.voiceMemoRecordingState = VoiceMemoRecordingState_Idle; - [self.inputToolbarDelegate voiceMemoGestureDidCancel]; - } - break; - case UIGestureRecognizerStateBegan: - switch (self.voiceMemoRecordingState) { - case VoiceMemoRecordingState_Idle: - break; - case VoiceMemoRecordingState_RecordingHeld: - OWSFailDebug(@"while recording held, shouldn't be possible to restart gesture."); - [self.inputToolbarDelegate voiceMemoGestureDidCancel]; - break; - case VoiceMemoRecordingState_RecordingLocked: - OWSFailDebug(@"once locked, shouldn't be possible to interact with gesture."); - [self.inputToolbarDelegate voiceMemoGestureDidCancel]; - break; - } - // Start voice message. - self.voiceMemoRecordingState = VoiceMemoRecordingState_RecordingHeld; - self.voiceMemoGestureStartLocation = [sender locationInView:self]; - [self.inputToolbarDelegate voiceMemoGestureDidStart]; - break; - case UIGestureRecognizerStateChanged: - if (self.isRecordingVoiceMemo) { - // Check for "slide to cancel" gesture. - CGPoint location = [sender locationInView:self]; - // For LTR/RTL, swiping in either direction will cancel. - // This is okay because there's only space on screen to perform the - // gesture in one direction. - CGFloat xOffset = fabs(self.voiceMemoGestureStartLocation.x - location.x); - CGFloat yOffset = fabs(self.voiceMemoGestureStartLocation.y - location.y); - - // require a certain threshold before we consider the user to be - // interacting with the lock ui, otherwise there's perceptible wobble - // of the lock slider even when the user isn't intended to interact with it. - const CGFloat kLockThresholdPoints = 20.f; - const CGFloat kLockOffsetPoints = 80.f; - CGFloat yOffsetBeyondThreshold = MAX(yOffset - kLockThresholdPoints, 0); - CGFloat lockAlpha = yOffsetBeyondThreshold / kLockOffsetPoints; - BOOL isLocked = lockAlpha >= 1.f; - if (isLocked) { - switch (self.voiceMemoRecordingState) { - case VoiceMemoRecordingState_RecordingHeld: - self.voiceMemoRecordingState = VoiceMemoRecordingState_RecordingLocked; - [self.inputToolbarDelegate voiceMemoGestureDidLock]; - [self.inputToolbarDelegate voiceMemoGestureDidUpdateCancelWithRatioComplete:0]; - break; - case VoiceMemoRecordingState_RecordingLocked: - // already locked - break; - case VoiceMemoRecordingState_Idle: - OWSFailDebug(@"failure: unexpeceted idle state"); - [self.inputToolbarDelegate voiceMemoGestureDidCancel]; - break; - } - } else { - [self.voiceMemoLockView updateWithRatioComplete:lockAlpha]; - - // The lower this value, the easier it is to cancel by accident. - // The higher this value, the harder it is to cancel. - const CGFloat kCancelOffsetPoints = 100.f; - CGFloat cancelAlpha = xOffset / kCancelOffsetPoints; - BOOL isCancelled = cancelAlpha >= 1.f; - if (isCancelled) { - self.voiceMemoRecordingState = VoiceMemoRecordingState_Idle; - [self.inputToolbarDelegate voiceMemoGestureDidCancel]; - break; - } else { - [self.inputToolbarDelegate voiceMemoGestureDidUpdateCancelWithRatioComplete:cancelAlpha]; - } - } - } - break; - case UIGestureRecognizerStateEnded: - switch (self.voiceMemoRecordingState) { - case VoiceMemoRecordingState_Idle: - break; - case VoiceMemoRecordingState_RecordingHeld: - // End voice message. - self.voiceMemoRecordingState = VoiceMemoRecordingState_Idle; - [self.inputToolbarDelegate voiceMemoGestureDidComplete]; - break; - case VoiceMemoRecordingState_RecordingLocked: - // Continue recording. - break; - } - break; - } -} - -#pragma mark - Voice Memo - -- (BOOL)isRecordingVoiceMemo -{ - switch (self.voiceMemoRecordingState) { - case VoiceMemoRecordingState_Idle: - return NO; - case VoiceMemoRecordingState_RecordingHeld: - case VoiceMemoRecordingState_RecordingLocked: - return YES; - } -} - -- (void)showVoiceMemoUI -{ - OWSAssertIsOnMainThread(); - - self.voiceMemoStartTime = [NSDate date]; - - [self.voiceMemoUI removeFromSuperview]; - [self.voiceMemoLockView removeFromSuperview]; - - self.voiceMemoUI = [UIView new]; - self.voiceMemoUI.backgroundColor = LKColors.composeViewBackground; - [self addSubview:self.voiceMemoUI]; - [self.voiceMemoUI autoPinEdgesToSuperviewEdges]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _voiceMemoUI); - - self.voiceMemoContentView = [UIView new]; - [self.voiceMemoUI addSubview:self.voiceMemoContentView]; - - [self.voiceMemoContentView autoPinLeadingToEdgeOfView:self.voiceMemoUI]; - [self.voiceMemoContentView autoPinTopToSuperviewMargin]; - [self.voiceMemoContentView autoPinTrailingToEdgeOfView:self.voiceMemoUI]; - [self.voiceMemoContentView autoPinBottomToSuperviewMargin]; - - self.recordingLabel = [UILabel new]; - self.recordingLabel.textColor = LKColors.destructive; - self.recordingLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize]; - [self.voiceMemoContentView addSubview:self.recordingLabel]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _recordingLabel); - - VoiceMemoLockView *voiceMemoLockView = [VoiceMemoLockView new]; - self.voiceMemoLockView = voiceMemoLockView; - [self addSubview:voiceMemoLockView]; - [voiceMemoLockView autoPinTrailingToSuperviewMargin]; - [voiceMemoLockView autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:self.voiceMemoContentView]; - [voiceMemoLockView setCompressionResistanceHigh]; - - [self updateVoiceMemo]; - - UIImage *icon = [UIImage imageNamed:@"Microphone"]; - OWSAssertDebug(icon); - UIImageView *imageView = - [[UIImageView alloc] initWithImage:[icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]]; - imageView.tintColor = LKColors.destructive; - [imageView setContentHuggingHigh]; - [self.voiceMemoContentView addSubview:imageView]; - - NSMutableAttributedString *cancelString = [NSMutableAttributedString new]; - const CGFloat cancelArrowFontSize = ScaleFromIPhone5To7Plus(18.4, 20.f); - const CGFloat cancelFontSize = ScaleFromIPhone5To7Plus(LKValues.smallFontSize, LKValues.mediumFontSize); - NSString *arrowHead = (CurrentAppContext().isRTL ? @"\uf105" : @"\uf104"); - [cancelString - appendAttributedString:[[NSAttributedString alloc] - initWithString:arrowHead - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : LKColors.destructive, - NSBaselineOffsetAttributeName : @(-1.f), - }]]; - [cancelString - appendAttributedString:[[NSAttributedString alloc] - initWithString:@" " - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : LKColors.destructive, - NSBaselineOffsetAttributeName : @(-1.f), - }]]; - [cancelString - appendAttributedString:[[NSAttributedString alloc] - initWithString:NSLocalizedString(@"VOICE_MESSAGE_CANCEL_INSTRUCTIONS", - @"Indicates how to cancel a voice message.") - attributes:@{ - NSFontAttributeName : [UIFont systemFontOfSize:cancelFontSize], - NSForegroundColorAttributeName : LKColors.destructive, - }]]; - [cancelString - appendAttributedString:[[NSAttributedString alloc] - initWithString:@" " - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : LKColors.destructive, - NSBaselineOffsetAttributeName : @(-1.f), - }]]; - [cancelString - appendAttributedString:[[NSAttributedString alloc] - initWithString:arrowHead - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : LKColors.destructive, - NSBaselineOffsetAttributeName : @(-1.f), - }]]; - UILabel *cancelLabel = [UILabel new]; - self.voiceMemoCancelLabel = cancelLabel; - cancelLabel.attributedText = cancelString; - [self.voiceMemoContentView addSubview:cancelLabel]; - - const CGFloat kRedCircleSize = 100.f; - UIView *redCircleView = [UIView new]; - self.voiceMemoRedRecordingCircle = redCircleView; - redCircleView.backgroundColor = LKColors.destructive; - redCircleView.layer.cornerRadius = kRedCircleSize * 0.5f; - [redCircleView autoSetDimension:ALDimensionWidth toSize:kRedCircleSize]; - [redCircleView autoSetDimension:ALDimensionHeight toSize:kRedCircleSize]; - [self.voiceMemoContentView addSubview:redCircleView]; - [redCircleView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.voiceMemoButton]; - [redCircleView autoAlignAxis:ALAxisVertical toSameAxisOfView:self.voiceMemoButton]; - - UIImage *whiteIcon = [UIImage imageNamed:@"Microphone"]; - OWSAssertDebug(whiteIcon); - UIImageView *whiteIconView = [[UIImageView alloc] initWithImage:whiteIcon]; - [redCircleView addSubview:whiteIconView]; - [whiteIconView autoCenterInSuperview]; - - [imageView autoVCenterInSuperview]; - [imageView autoPinLeadingToSuperviewMarginWithInset:LKValues.smallSpacing]; - [self.recordingLabel autoVCenterInSuperview]; - [self.recordingLabel autoPinLeadingToTrailingEdgeOfView:imageView offset:12.f]; - [cancelLabel autoVCenterInSuperview]; - [cancelLabel autoHCenterInSuperview]; - [self.voiceMemoUI layoutIfNeeded]; - - // Slide in the "slide to cancel" label. - CGRect cancelLabelStartFrame = cancelLabel.frame; - CGRect cancelLabelEndFrame = cancelLabel.frame; - cancelLabelStartFrame.origin.x - = (CurrentAppContext().isRTL ? -self.voiceMemoUI.bounds.size.width : self.voiceMemoUI.bounds.size.width); - cancelLabel.frame = cancelLabelStartFrame; - - voiceMemoLockView.transform = CGAffineTransformMakeScale(0.0, 0.0); - [voiceMemoLockView layoutIfNeeded]; - [UIView animateWithDuration:0.2f - delay:1.f - options:0 - animations:^{ - voiceMemoLockView.transform = CGAffineTransformIdentity; - } - completion:nil]; - - [UIView animateWithDuration:0.35f - delay:0.f - options:UIViewAnimationOptionCurveEaseOut - animations:^{ - cancelLabel.frame = cancelLabelEndFrame; - } - completion:nil]; - - // Pulse the icon. - imageView.layer.opacity = 1.f; - [UIView animateWithDuration:0.5f - delay:0.2f - options:UIViewAnimationOptionRepeat | UIViewAnimationOptionAutoreverse - | UIViewAnimationOptionCurveEaseIn - animations:^{ - imageView.layer.opacity = 0.f; - } - completion:nil]; - - // Fade in the view. - self.voiceMemoUI.layer.opacity = 0.f; - [UIView animateWithDuration:0.2f - animations:^{ - self.voiceMemoUI.layer.opacity = 1.f; - } - completion:^(BOOL finished) { - if (finished) { - self.voiceMemoUI.layer.opacity = 1.f; - } - }]; - - [self.voiceMemoUpdateTimer invalidate]; - self.voiceMemoUpdateTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.1f - target:self - selector:@selector(updateVoiceMemo) - userInfo:nil - repeats:YES]; -} - -- (void)hideVoiceMemoUI:(BOOL)animated -{ - OWSAssertIsOnMainThread(); - - self.voiceMemoRecordingState = VoiceMemoRecordingState_Idle; - - UIView *oldVoiceMemoUI = self.voiceMemoUI; - UIView *oldVoiceMemoLockView = self.voiceMemoLockView; - - self.voiceMemoUI = nil; - self.voiceMemoCancelLabel = nil; - self.voiceMemoRedRecordingCircle = nil; - self.voiceMemoContentView = nil; - self.voiceMemoLockView = nil; - self.recordingLabel = nil; - - [self.voiceMemoUpdateTimer invalidate]; - self.voiceMemoUpdateTimer = nil; - - [oldVoiceMemoUI.layer removeAllAnimations]; - - if (animated) { - [UIView animateWithDuration:0.35f - animations:^{ - oldVoiceMemoUI.layer.opacity = 0.f; - oldVoiceMemoLockView.layer.opacity = 0.f; - } - completion:^(BOOL finished) { - [oldVoiceMemoUI removeFromSuperview]; - [oldVoiceMemoLockView removeFromSuperview]; - }]; - } else { - [oldVoiceMemoUI removeFromSuperview]; - [oldVoiceMemoLockView removeFromSuperview]; - } -} - -- (void)lockVoiceMemoUI -{ - __weak __typeof(self) weakSelf = self; - - UIButton *sendVoiceMemoButton = [[OWSButton alloc] initWithBlock:^{ - [weakSelf.inputToolbarDelegate voiceMemoGestureDidComplete]; - }]; - [sendVoiceMemoButton setTitle:MessageStrings.sendButton forState:UIControlStateNormal]; - [sendVoiceMemoButton setTitleColor:LKColors.text forState:UIControlStateNormal]; - sendVoiceMemoButton.titleLabel.font = [UIFont boldSystemFontOfSize:LKValues.mediumFontSize]; - sendVoiceMemoButton.alpha = 0; - [self.voiceMemoContentView addSubview:sendVoiceMemoButton]; - [sendVoiceMemoButton autoPinEdgeToSuperviewMargin:ALEdgeTrailing withInset:LKValues.smallSpacing]; - [sendVoiceMemoButton autoVCenterInSuperview]; - [sendVoiceMemoButton setCompressionResistanceHigh]; - [sendVoiceMemoButton setContentHuggingHigh]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, sendVoiceMemoButton); - - UIButton *cancelButton = [[OWSButton alloc] initWithBlock:^{ - [weakSelf.inputToolbarDelegate voiceMemoGestureDidCancel]; - }]; - [cancelButton setTitle:CommonStrings.cancelButton forState:UIControlStateNormal]; - [cancelButton setTitleColor:LKColors.destructive forState:UIControlStateNormal]; - cancelButton.titleLabel.font = [UIFont boldSystemFontOfSize:LKValues.mediumFontSize]; - cancelButton.alpha = 0; - cancelButton.titleLabel.textAlignment = NSTextAlignmentCenter; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, cancelButton); - - [self.voiceMemoContentView addSubview:cancelButton]; - OWSAssert(self.recordingLabel != nil); - [self.recordingLabel setContentHuggingHigh]; - - [NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow - forConstraints:^{ - [cancelButton autoHCenterInSuperview]; - }]; - [cancelButton autoPinEdge:ALEdgeLeading - toEdge:ALEdgeTrailing - ofView:self.recordingLabel - withOffset:4 - relation:NSLayoutRelationGreaterThanOrEqual]; - [cancelButton autoPinEdge:ALEdgeTrailing - toEdge:ALEdgeLeading - ofView:sendVoiceMemoButton - withOffset:-4 - relation:NSLayoutRelationLessThanOrEqual]; - [cancelButton autoVCenterInSuperview]; - - [self.voiceMemoContentView layoutIfNeeded]; - [UIView animateWithDuration:0.35 - animations:^{ - self.voiceMemoCancelLabel.alpha = 0; - self.voiceMemoRedRecordingCircle.alpha = 0; - self.voiceMemoLockView.transform = CGAffineTransformMakeScale(0, 0); - cancelButton.alpha = 1.0; - sendVoiceMemoButton.alpha = 1.0; - } - completion:^(BOOL finished) { - [self.voiceMemoCancelLabel removeFromSuperview]; - [self.voiceMemoRedRecordingCircle removeFromSuperview]; - [self.voiceMemoLockView removeFromSuperview]; - }]; -} - -- (void)setVoiceMemoUICancelAlpha:(CGFloat)cancelAlpha -{ - OWSAssertIsOnMainThread(); - - // Fade out the voice message views as the cancel gesture - // proceeds as feedback. - self.voiceMemoContentView.layer.opacity = MAX(0.f, MIN(1.f, 1.f - (float)cancelAlpha)); -} - -- (void)updateVoiceMemo -{ - OWSAssertIsOnMainThread(); - - NSTimeInterval durationSeconds = fabs([self.voiceMemoStartTime timeIntervalSinceNow]); - self.recordingLabel.text = [OWSFormat formatDurationSeconds:(long)round(durationSeconds)]; - [self.recordingLabel sizeToFit]; -} - -- (void)cancelVoiceMemoIfNecessary -{ - if (self.isRecordingVoiceMemo) { - self.voiceMemoRecordingState = VoiceMemoRecordingState_Idle; - } -} - -#pragma mark - Event Handlers - -- (void)sendButtonPressed -{ - OWSAssertDebug(self.inputToolbarDelegate); - - [self.inputToolbarDelegate sendButtonPressed]; -} - -- (void)attachmentButtonPressed -{ - OWSAssertDebug(self.inputToolbarDelegate); - - [self.inputToolbarDelegate attachmentButtonPressed]; -} - -#pragma mark - ConversationTextViewToolbarDelegate - -- (void)textViewDidChange:(UITextView *)textView -{ - OWSAssertDebug(self.inputToolbarDelegate); - [self ensureShouldShowVoiceMemoButtonAnimated:YES doLayout:YES]; - [self updateHeightWithTextView:textView]; - [self updateInputLinkPreview]; -} - -- (void)textViewDidChangeSelection:(UITextView *)textView -{ - [self updateInputLinkPreview]; -} - -- (void)updateHeightWithTextView:(UITextView *)textView -{ - // compute new height assuming width is unchanged - CGSize currentSize = textView.frame.size; - - CGFloat fixedWidth = currentSize.width; - CGSize contentSize = [textView sizeThatFits:CGSizeMake(fixedWidth, CGFLOAT_MAX)]; - - // `textView.contentSize` isn't accurate when restoring a multiline draft, so we compute it here. - textView.contentSize = contentSize; - - CGFloat newHeight = CGFloatClamp(contentSize.height, kMinTextViewHeight, kMaxTextViewHeight); - - if (newHeight != self.textViewHeight) { - self.textViewHeight = newHeight; - OWSAssertDebug(self.textViewHeightConstraint); - self.textViewHeightConstraint.constant = newHeight; - [self invalidateIntrinsicContentSize]; - } -} - -#pragma mark QuotedReplyPreviewViewDelegate - -- (void)quotedReplyPreviewDidPressCancel:(QuotedReplyPreview *)preview -{ - self.quotedReply = nil; -} - -#pragma mark - Link Preview - -- (void)updateInputLinkPreview -{ - OWSAssertIsOnMainThread(); - - NSString *body = - [[self messageText] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (body.length < 1) { - [self clearLinkPreviewStateAndView]; - self.wasLinkPreviewCancelled = NO; - return; - } - - if (self.wasLinkPreviewCancelled) { - [self clearLinkPreviewStateAndView]; - return; - } - - // Don't include link previews for oversize text messages. - if ([body lengthOfBytesUsingEncoding:NSUTF8StringEncoding] >= kOversizeTextMessageSizeThreshold) { - [self clearLinkPreviewStateAndView]; - return; - } - - // It's key that we use the *raw/unstripped* text, so we can reconcile cursor position with the - // selectedRange. - NSString *_Nullable previewUrl = [OWSLinkPreview previewUrlForRawBodyText:self.inputTextView.text selectedRange:self.inputTextView.selectedRange]; - - if ([previewUrl hasSuffix:@".gif"]) { - return [self clearLinkPreviewStateAndView]; - } - - if (previewUrl.length < 1) { - return [self clearLinkPreviewStateAndView]; - } - - if (self.inputLinkPreview && [self.inputLinkPreview.previewUrl isEqualToString:previewUrl]) { - return; // No need to update. - } - - InputLinkPreview *inputLinkPreview = [InputLinkPreview new]; - self.inputLinkPreview = inputLinkPreview; - self.inputLinkPreview.previewUrl = previewUrl; - - [self ensureLinkPreviewViewWithState:[LinkPreviewLoading new]]; - - __weak ConversationInputToolbar *weakSelf = self; - [[OWSLinkPreview tryToBuildPreviewInfoObjcWithPreviewUrl:previewUrl] - .then(^(OWSLinkPreviewDraft *linkPreviewDraft) { - ConversationInputToolbar *_Nullable strongSelf = weakSelf; - if (!strongSelf) { - return; - } - if (strongSelf.inputLinkPreview != inputLinkPreview) { - // Obsolete callback. - return; - } - inputLinkPreview.linkPreviewDraft = linkPreviewDraft; - LinkPreviewDraft *viewState = [[LinkPreviewDraft alloc] initWithLinkPreviewDraft:linkPreviewDraft]; - [strongSelf ensureLinkPreviewViewWithState:viewState]; - }) - .catch(^(id error) { - // The link preview could not be loaded. - [weakSelf clearLinkPreviewView]; - }) retainUntilComplete]; -} - -- (void)ensureLinkPreviewViewWithState:(id)state -{ - OWSAssertIsOnMainThread(); - - [self clearLinkPreviewView]; - - LinkPreviewView *linkPreviewView = [[LinkPreviewView alloc] initWithDraftDelegate:self]; - linkPreviewView.state = state; - linkPreviewView.hasAsymmetricalRounding = !self.quotedReply; - self.linkPreviewView = linkPreviewView; - - self.linkPreviewWrapper.hidden = NO; - self.linkPreviewWrapper.backgroundColor = LKColors.composeViewTextFieldBackground; - [self.linkPreviewWrapper addSubview:linkPreviewView]; - [linkPreviewView ows_autoPinToSuperviewMargins]; -} - -- (void)clearLinkPreviewStateAndView -{ - OWSAssertIsOnMainThread(); - - self.inputLinkPreview = nil; - self.linkPreviewView = nil; - - [self clearLinkPreviewView]; -} - -- (void)clearLinkPreviewView -{ - OWSAssertIsOnMainThread(); - - // Clear old link preview state. - for (UIView *subview in self.linkPreviewWrapper.subviews) { - [subview removeFromSuperview]; - } - self.linkPreviewWrapper.hidden = YES; -} - -- (nullable OWSLinkPreviewDraft *)linkPreviewDraft -{ - OWSAssertIsOnMainThread(); - - if (!self.inputLinkPreview) { - return nil; - } - if (self.wasLinkPreviewCancelled) { - return nil; - } - return self.inputLinkPreview.linkPreviewDraft; -} - -#pragma mark - LinkPreviewViewDraftDelegate - -- (BOOL)linkPreviewCanCancel -{ - OWSAssertIsOnMainThread(); - - return YES; -} - -- (void)linkPreviewDidCancel -{ - OWSAssertIsOnMainThread(); - - self.wasLinkPreviewCancelled = YES; - - self.inputLinkPreview = nil; - [self clearLinkPreviewStateAndView]; -} - -- (void)hideInputMethod -{ - self.hStack.hidden = YES; - self.borderView.hidden = YES; -} - -#pragma mark - Mention Candidate Selection View - -- (void)showMentionCandidateSelectionViewFor:(NSArray *)mentionCandidates in:(TSThread *)thread -{ - SNOpenGroup *publicChat = [LKStorage.shared getOpenGroupForThreadID:thread.uniqueId]; - if (publicChat != nil) { - self.mentionCandidateSelectionView.publicChatServer = publicChat.server; - [self.mentionCandidateSelectionView setPublicChatChannel:publicChat.channel]; - } - self.mentionCandidateSelectionView.mentionCandidates = mentionCandidates; - self.mentionCandidateSelectionViewSizeConstraint.constant = MIN(mentionCandidates.count, 4) * 42; - self.mentionCandidateSelectionView.alpha = 1; - [self setNeedsLayout]; - [self layoutIfNeeded]; -} - -- (void)hideMentionCandidateSelectionView -{ - self.mentionCandidateSelectionViewSizeConstraint.constant = 0; - self.mentionCandidateSelectionView.alpha = 0; - [self setNeedsLayout]; - [self layoutIfNeeded]; - [self.mentionCandidateSelectionView.tableView setContentOffset:CGPointMake(0, 0)]; -} - -- (void)handleMentionCandidateSelected:(LKMention *)mentionCandidate from:(LKMentionCandidateSelectionView *)mentionCandidateSelectionView -{ - [self.inputToolbarDelegate handleMentionCandidateSelected:mentionCandidate from:mentionCandidateSelectionView]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationScrollButton.h b/Session/Conversations/ConversationScrollButton.h deleted file mode 100644 index ba830bfa7..000000000 --- a/Session/Conversations/ConversationScrollButton.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface ConversationScrollButton : UIButton - -@property (nonatomic) BOOL hasUnreadMessages; - -+ (CGFloat)buttonSize; - -- (nullable instancetype)initWithIconText:(NSString *)iconText; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationScrollButton.m b/Session/Conversations/ConversationScrollButton.m deleted file mode 100644 index 819b02220..000000000 --- a/Session/Conversations/ConversationScrollButton.m +++ /dev/null @@ -1,99 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationScrollButton.h" -#import "UIColor+OWS.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" -#import -#import "Session-Swift.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface ConversationScrollButton () - -@property (nonatomic) NSString *iconText; -@property (nonatomic) UILabel *iconLabel; -@property (nonatomic) UIView *circleView; - -@end - -#pragma mark - - -@implementation ConversationScrollButton - -- (nullable instancetype)initWithIconText:(NSString *)iconText -{ - self = [super initWithFrame:CGRectZero]; - if (!self) { - return self; - } - - self.iconText = iconText; - - [self createContents]; - - return self; -} - -+ (CGFloat)circleSize -{ - return ScaleFromIPhone5To7Plus(35.f, 40.f); -} - -+ (CGFloat)buttonSize -{ - return self.circleSize + 2 * 15.f; -} - -- (void)createContents -{ - UILabel *iconLabel = [UILabel new]; - self.iconLabel = iconLabel; - iconLabel.userInteractionEnabled = NO; - - const CGFloat circleSize = self.class.circleSize; - UIView *circleView = [UIView new]; - self.circleView = circleView; - circleView.userInteractionEnabled = NO; - circleView.layer.cornerRadius = circleSize * 0.5f; - circleView.layer.borderColor = [LKColors.text colorWithAlphaComponent:LKValues.composeViewTextFieldBorderOpacity].CGColor; - circleView.layer.borderWidth = LKValues.composeViewTextFieldBorderThickness; - [circleView autoSetDimension:ALDimensionWidth toSize:circleSize]; - [circleView autoSetDimension:ALDimensionHeight toSize:circleSize]; - - [self addSubview:circleView]; - [self addSubview:iconLabel]; - [circleView autoCenterInSuperview]; - [iconLabel autoCenterInSuperview]; - - [self updateColors]; -} - -- (void)setHasUnreadMessages:(BOOL)hasUnreadMessages -{ - _hasUnreadMessages = hasUnreadMessages; - - [self updateColors]; -} - -- (void)updateColors -{ - UIColor *foregroundColor = LKColors.text; - UIColor *backgroundColor = LKColors.composeViewBackground; - - const CGFloat circleSize = self.class.circleSize; - self.circleView.backgroundColor = backgroundColor; - self.iconLabel.attributedText = - [[NSAttributedString alloc] initWithString:self.iconText - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:circleSize * 0.75f], - NSForegroundColorAttributeName : foregroundColor, - NSBaselineOffsetAttributeName : @(-0.5f), - }]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 5989dc851..7b7ed47b1 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -17,7 +17,7 @@ public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate } @objc -public class ConversationSearchController: NSObject { +public class ConversationSearchController : NSObject { @objc public static let kMinimumSearchTextLength: UInt = 2 @@ -31,7 +31,7 @@ public class ConversationSearchController: NSObject { let thread: TSThread @objc - public let resultsBar: SearchResultsBar = SearchResultsBar(frame: .zero) + public let resultsBar: SearchResultsBar = SearchResultsBar() // MARK: Initializer @@ -45,14 +45,12 @@ public class ConversationSearchController: NSObject { uiSearchController.searchResultsUpdater = self uiSearchController.hidesNavigationBarDuringPresentation = false - uiSearchController.dimsBackgroundDuringPresentation = false + if #available(iOS 13, *) { + // Do nothing + } else { + uiSearchController.dimsBackgroundDuringPresentation = false + } uiSearchController.searchBar.inputAccessoryView = resultsBar - - applyTheme() - } - - func applyTheme() { - OWSSearchBar.applyTheme(to: uiSearchController.searchBar) } // MARK: Dependencies @@ -62,7 +60,8 @@ public class ConversationSearchController: NSObject { } } -extension ConversationSearchController: UISearchControllerDelegate { +extension ConversationSearchController : UISearchControllerDelegate { + public func didPresentSearchController(_ searchController: UISearchController) { Logger.verbose("") delegate?.didPresentSearchController?(searchController) @@ -74,7 +73,8 @@ extension ConversationSearchController: UISearchControllerDelegate { } } -extension ConversationSearchController: UISearchResultsUpdating { +extension ConversationSearchController : UISearchResultsUpdating { + var dbSearcher: FullTextSearcher { return FullTextSearcher.shared } @@ -88,7 +88,6 @@ extension ConversationSearchController: UISearchResultsUpdating { return } let searchText = FullTextSearchFinder.normalize(text: rawSearchText) - BenchManager.startEvent(title: "Conversation Search", eventId: searchText) guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else { self.resultsBar.updateResults(resultSet: nil) @@ -112,7 +111,8 @@ extension ConversationSearchController: UISearchResultsUpdating { } } -extension ConversationSearchController: SearchResultsBarDelegate { +extension ConversationSearchController : SearchResultsBarDelegate { + func searchResultsBar(_ searchResultsBar: SearchResultsBar, setCurrentIndex currentIndex: Int, resultSet: ConversationScreenSearchResultSet) { @@ -126,68 +126,95 @@ extension ConversationSearchController: SearchResultsBarDelegate { } } -protocol SearchResultsBarDelegate: AnyObject { +protocol SearchResultsBarDelegate : AnyObject { + func searchResultsBar(_ searchResultsBar: SearchResultsBar, setCurrentIndex currentIndex: Int, resultSet: ConversationScreenSearchResultSet) } -public class SearchResultsBar: UIToolbar { - +public final class SearchResultsBar : UIView { + private var resultSet: ConversationScreenSearchResultSet? + var currentIndex: Int? weak var resultsBarDelegate: SearchResultsBarDelegate? - - var showLessRecentButton: UIBarButtonItem! - var showMoreRecentButton: UIBarButtonItem! - let labelItem: UIBarButtonItem - - var resultSet: ConversationScreenSearchResultSet? - + + public override var intrinsicContentSize: CGSize { CGSize.zero } + + private lazy var label: UILabel = { + let result = UILabel() + result.text = "Test" + result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + return result + }() + + private lazy var upButton: UIButton = { + let icon = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate) + let result = UIButton() + result.setImage(icon, for: UIControl.State.normal) + result.tintColor = Colors.accent + result.addTarget(self, action: #selector(handleUpButtonTapped), for: UIControl.Event.touchUpInside) + return result + }() + + private lazy var downButton: UIButton = { + let icon = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate) + let result = UIButton() + result.setImage(icon, for: UIControl.State.normal) + result.tintColor = Colors.accent + result.addTarget(self, action: #selector(handleDownButtonTapped), for: UIControl.Event.touchUpInside) + return result + }() + override init(frame: CGRect) { - - labelItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil) - labelItem.setTitleTextAttributes([ .font : UIFont.systemFont(ofSize: Values.mediumFontSize) ], for: UIControl.State.normal) - super.init(frame: frame) - - let leftExteriorChevronMargin: CGFloat - let leftInteriorChevronMargin: CGFloat - if CurrentAppContext().isRTL { - leftExteriorChevronMargin = 8 - leftInteriorChevronMargin = 0 - } else { - leftExteriorChevronMargin = 0 - leftInteriorChevronMargin = 8 - } - - let upChevron = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate) - showLessRecentButton = UIBarButtonItem(image: upChevron, style: .plain, target: self, action: #selector(didTapShowLessRecent)) - showLessRecentButton.imageInsets = UIEdgeInsets(top: 2, left: leftExteriorChevronMargin, bottom: 2, right: leftInteriorChevronMargin) - showLessRecentButton.tintColor = Colors.accent - - let downChevron = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate) - showMoreRecentButton = UIBarButtonItem(image: downChevron, style: .plain, target: self, action: #selector(didTapShowMoreRecent)) - showMoreRecentButton.imageInsets = UIEdgeInsets(top: 2, left: leftInteriorChevronMargin, bottom: 2, right: leftExteriorChevronMargin) - showMoreRecentButton.tintColor = Colors.accent - - let spacer1 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - let spacer2 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - - self.items = [showLessRecentButton, showMoreRecentButton, spacer1, labelItem, spacer2] - - self.isTranslucent = false - self.isOpaque = true - self.barTintColor = Colors.navigationBarBackground - - self.autoresizingMask = .flexibleHeight - self.translatesAutoresizingMaskIntoConstraints = false + setUpViewHierarchy() } - - required init?(coder aDecoder: NSCoder) { - notImplemented() + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() } - + + private func setUpViewHierarchy() { + autoresizingMask = .flexibleHeight + // Background & blur + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + addSubview(backgroundView) + backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + addSubview(blurView) + blurView.pin(to: self) + // Separator + let separator = UIView() + separator.backgroundColor = Colors.text.withAlphaComponent(0.2) + separator.set(.height, to: 1 / UIScreen.main.scale) + addSubview(separator) + separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) + // Spacers + let spacer1 = UIView.hStretchingSpacer() + let spacer2 = UIView.hStretchingSpacer() + // Button containers + let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)) + let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0)) + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ]) + mainStackView.axis = .horizontal + mainStackView.spacing = Values.mediumSpacing + mainStackView.isLayoutMarginsRelativeArrangement = true + mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing) + addSubview(mainStackView) + mainStackView.pin(.top, to: .bottom, of: separator) + mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) + mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) + // Remaining constraints + label.center(.horizontal, in: self) + } + @objc - public func didTapShowLessRecent() { + public func handleUpButtonTapped() { Logger.debug("") guard let resultSet = resultSet else { owsFailDebug("resultSet was unexpectedly nil") @@ -211,7 +238,7 @@ public class SearchResultsBar: UIToolbar { } @objc - public func didTapShowMoreRecent() { + public func handleDownButtonTapped() { Logger.debug("") guard let resultSet = resultSet else { owsFailDebug("resultSet was unexpectedly nil") @@ -234,10 +261,6 @@ public class SearchResultsBar: UIToolbar { resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) } - var currentIndex: Int? - - // MARK: - func updateResults(resultSet: ConversationScreenSearchResultSet?) { if let resultSet = resultSet { if resultSet.messages.count > 0 { @@ -259,17 +282,17 @@ public class SearchResultsBar: UIToolbar { func updateBarItems() { guard let resultSet = resultSet else { - labelItem.title = nil - showMoreRecentButton.isEnabled = false - showLessRecentButton.isEnabled = false + label.text = "" + downButton.isEnabled = false + upButton.isEnabled = false return } switch resultSet.messages.count { case 0: - labelItem.title = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string") + label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string") case 1: - labelItem.title = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string") + label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string") default: let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT", comment: "keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}}") @@ -278,15 +301,15 @@ public class SearchResultsBar: UIToolbar { owsFailDebug("currentIndex was unexpectedly nil") return } - labelItem.title = String(format: format, currentIndex + 1, resultSet.messages.count) + label.text = String(format: format, currentIndex + 1, resultSet.messages.count) } if let currentIndex = currentIndex { - showMoreRecentButton.isEnabled = currentIndex > 0 - showLessRecentButton.isEnabled = currentIndex + 1 < resultSet.messages.count + downButton.isEnabled = currentIndex > 0 + upButton.isEnabled = currentIndex + 1 < resultSet.messages.count } else { - showMoreRecentButton.isEnabled = false - showLessRecentButton.isEnabled = false + downButton.isEnabled = false + upButton.isEnabled = false } } } diff --git a/Session/Conversations/ConversationTitleView.swift b/Session/Conversations/ConversationTitleView.swift deleted file mode 100644 index 67ed1a24e..000000000 --- a/Session/Conversations/ConversationTitleView.swift +++ /dev/null @@ -1,210 +0,0 @@ - -@objc(LKConversationTitleView) -final class ConversationTitleView : UIView { - private let thread: TSThread - private var currentStatus: Status? { didSet { updateSubtitleForCurrentStatus() } } - private var handledMessageTimestamps: Set = [] - - // MARK: Types - private enum Status : Int { - case calculatingPoW = 1 - case routing = 2 - case messageSending = 3 - case messageSent = 4 - case messageFailed = 5 - } - - // MARK: Components - private lazy var profilePictureView: ProfilePictureView = { - let result = ProfilePictureView() - let size: CGFloat = 40 - result.set(.width, to: size) - result.set(.height, to: size) - result.size = size - return result - }() - - private lazy var titleLabel: UILabel = { - let result = UILabel() - result.textColor = Colors.text - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) - result.lineBreakMode = .byTruncatingTail - return result - }() - - private lazy var subtitleLabel: UILabel = { - let result = UILabel() - result.textColor = Colors.text - result.font = .systemFont(ofSize: 13) - result.lineBreakMode = .byTruncatingTail - return result - }() - - // MARK: Lifecycle - @objc init(thread: TSThread) { - self.thread = thread - super.init(frame: CGRect.zero) - setUpViewHierarchy() - updateTitle() - updateProfilePicture() - updateSubtitleForCurrentStatus() - let notificationCenter = NotificationCenter.default - notificationCenter.addObserver(self, selector: #selector(handleProfileChangedNotification(_:)), name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange), object: nil) - notificationCenter.addObserver(self, selector: #selector(handleCalculatingMessagePoWNotification(_:)), name: .calculatingMessagePoW, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleEncryptingMessageNotification(_:)), name: .encryptingMessage, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleMessageSendingNotification(_:)), name: .messageSending, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleMessageSentNotification(_:)), name: .messageSent, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleMessageSendingFailedNotification(_:)), name: .messageSendingFailed, object: nil) - } - - override init(frame: CGRect) { - preconditionFailure("Use init(thread:) instead.") - } - - required init?(coder: NSCoder) { - preconditionFailure("Use init(thread:) instead.") - } - - private func setUpViewHierarchy() { - let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) - labelStackView.axis = .vertical - labelStackView.alignment = .leading - let stackView = UIStackView(arrangedSubviews: [ profilePictureView, labelStackView ]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.spacing = 12 - addSubview(stackView) - stackView.pin(to: self) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: Updating - private func updateTitle() { - let title: String - if thread.isGroupThread() { - if thread.name().isEmpty { - title = GroupDisplayNameUtilities.getDefaultDisplayName(for: thread as! TSGroupThread) - } else { - title = thread.name() - } - } else { - if thread.isNoteToSelf() { - title = NSLocalizedString("Note to Self", comment: "") - } else { - let hexEncodedPublicKey = thread.contactIdentifier()! - title = UserDisplayNameUtilities.getPrivateChatDisplayName(for: hexEncodedPublicKey) ?? hexEncodedPublicKey - } - } - titleLabel.text = title - } - - private func updateProfilePicture() { - profilePictureView.update(for: thread) - } - - @objc private func handleProfileChangedNotification(_ notification: Notification) { - guard let hexEncodedPublicKey = notification.userInfo?[kNSNotificationKey_ProfileRecipientId] as? String, let thread = self.thread as? TSContactThread, - hexEncodedPublicKey == thread.contactIdentifier() else { return } - updateTitle() - updateProfilePicture() - } - - @objc private func handleCalculatingMessagePoWNotification(_ notification: Notification) { - guard let timestamp = notification.object as? NSNumber else { return } - setStatusIfNeeded(to: .calculatingPoW, forMessageWithTimestamp: timestamp) - } - - @objc private func handleEncryptingMessageNotification(_ notification: Notification) { - guard let timestamp = notification.object as? NSNumber else { return } - setStatusIfNeeded(to: .routing, forMessageWithTimestamp: timestamp) - } - - @objc private func handleMessageSendingNotification(_ notification: Notification) { - guard let timestamp = notification.object as? NSNumber else { return } - setStatusIfNeeded(to: .messageSending, forMessageWithTimestamp: timestamp) - } - - @objc private func handleMessageSentNotification(_ notification: Notification) { - guard let timestamp = notification.object as? NSNumber else { return } - setStatusIfNeeded(to: .messageSent, forMessageWithTimestamp: timestamp) - handledMessageTimestamps.insert(timestamp) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.clearStatusIfNeededForMessageWithTimestamp(timestamp) - } - } - - @objc private func handleMessageSendingFailedNotification(_ notification: Notification) { - guard let timestamp = notification.object as? NSNumber else { return } - clearStatusIfNeededForMessageWithTimestamp(timestamp) - } - - private func setStatusIfNeeded(to status: Status, forMessageWithTimestamp timestamp: NSNumber) { - guard !handledMessageTimestamps.contains(timestamp) else { return } - var uncheckedTargetInteraction: TSInteraction? = nil - thread.enumerateInteractions { interaction in - guard interaction.timestamp == timestamp.uint64Value else { return } - uncheckedTargetInteraction = interaction - } - guard let targetInteraction = uncheckedTargetInteraction, targetInteraction.interactionType() == .outgoingMessage, - status.rawValue > (currentStatus?.rawValue ?? 0) else { return } - currentStatus = status - } - - private func clearStatusIfNeededForMessageWithTimestamp(_ timestamp: NSNumber) { - var uncheckedTargetInteraction: TSInteraction? = nil - OWSPrimaryStorage.shared().dbReadConnection.read { transaction in - guard let interactionsByThread = transaction.ext(TSMessageDatabaseViewExtensionName) as? YapDatabaseViewTransaction else { return } - interactionsByThread.enumerateKeysAndObjects(inGroup: self.thread.uniqueId!) { _, _, object, _, _ in - guard let interaction = object as? TSInteraction, interaction.timestamp == timestamp.uint64Value else { return } - uncheckedTargetInteraction = interaction - } - } - guard let targetInteraction = uncheckedTargetInteraction, targetInteraction.interactionType() == .outgoingMessage else { return } - self.currentStatus = nil - } - - @objc func updateSubtitleForCurrentStatus() { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.subtitleLabel.isHidden = false - let subtitle = NSMutableAttributedString() - if let muteEndDate = self.thread.mutedUntilDate, self.thread.isMuted { - subtitle.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ])) - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale.current - dateFormatter.timeStyle = .medium - dateFormatter.dateStyle = .medium - subtitle.append(NSAttributedString(string: "Muted until " + dateFormatter.string(from: muteEndDate))) - } else if let thread = self.thread as? TSGroupThread { - var userCount: Int? - if thread.groupModel.groupType == .closedGroup { - userCount = GroupUtilities.getClosedGroupMemberCount(thread) - } else if thread.groupModel.groupType == .openGroup { - if let openGroup = Storage.shared.getOpenGroup(for: self.thread.uniqueId!) { - userCount = Storage.shared.getUserCount(forOpenGroupWithID: openGroup.id) - } - } - if let userCount = userCount { - subtitle.append(NSAttributedString(string: "\(userCount) members")) - } else if let hexEncodedPublicKey = (self.thread as? TSContactThread)?.contactIdentifier(), ECKeyPair.isValidHexEncodedPublicKey(candidate: hexEncodedPublicKey) { - subtitle.append(NSAttributedString(string: hexEncodedPublicKey)) - } else { - self.subtitleLabel.isHidden = true - } - } - else { - self.subtitleLabel.isHidden = true - } - self.subtitleLabel.attributedText = subtitle - self.titleLabel.font = .boldSystemFont(ofSize: self.subtitleLabel.isHidden ? Values.veryLargeFontSize : Values.mediumFontSize) - } - } - - // MARK: Layout - public override var intrinsicContentSize: CGSize { - return UIView.layoutFittingExpandedSize - } -} diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift new file mode 100644 index 000000000..1d54a52be --- /dev/null +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -0,0 +1,693 @@ +import CoreServices +import Photos + +extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate, + SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate { + + @objc func openSettings() { + let settingsVC = OWSConversationSettingsViewController() + settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection) + settingsVC.conversationSettingsViewDelegate = self + navigationController!.pushViewController(settingsVC, animated: true, completion: nil) + } + + func handleScrollToBottomButtonTapped() { + scrollToBottom(isAnimated: true) + } + + // MARK: Blocking + @objc func unblock() { + guard let thread = thread as? TSContactThread else { return } + let publicKey = thread.contactIdentifier() + UIView.animate(withDuration: 0.25, animations: { + self.blockedBanner.alpha = 0 + }, completion: { _ in + OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey) + }) + } + + func showBlockedModalIfNeeded() -> Bool { + guard let thread = thread as? TSContactThread else { return false } + let publicKey = thread.contactIdentifier() + guard OWSBlockingManager.shared().isRecipientIdBlocked(publicKey) else { return false } + let blockedModal = BlockedModal(publicKey: publicKey) + blockedModal.modalPresentationStyle = .overFullScreen + blockedModal.modalTransitionStyle = .crossDissolve + present(blockedModal, animated: true, completion: nil) + return true + } + + // MARK: Attachments + func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) { + dismiss(animated: true, completion: nil) + } + + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { + sendAttachments(attachments, with: messageText ?? "") + scrollToBottom(isAnimated: false) + resetMentions() + self.snInputView.text = "" + dismiss(animated: true) { } + } + + func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? { + return snInputView.text + } + + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) { + snInputView.text = newMessageText ?? "" + } + + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { + sendAttachments(attachments, with: messageText ?? "") + scrollToBottom(isAnimated: false) + resetMentions() + self.snInputView.text = "" + dismiss(animated: true) { } + } + + func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { + dismiss(animated: true, completion: nil) + } + + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { + snInputView.text = newMessageText ?? "" + } + + func handleCameraButtonTapped() { + guard requestCameraPermissionIfNeeded() else { return } + requestMicrophonePermissionIfNeeded { } + if AVAudioSession.sharedInstance().recordPermission != .granted { + SNLog("Proceeding without microphone access. Any recorded video will be silent.") + } + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst() + sendMediaNavController.sendMediaNavDelegate = self + sendMediaNavController.modalPresentationStyle = .fullScreen + present(sendMediaNavController, animated: true, completion: nil) + } + + func handleLibraryButtonTapped() { + let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst() + sendMediaNavController.sendMediaNavDelegate = self + sendMediaNavController.modalPresentationStyle = .fullScreen + present(sendMediaNavController, animated: true, completion: nil) + } + + func handleGIFButtonTapped() { + let gifVC = GifPickerViewController(thread: thread) + gifVC.delegate = self + let navController = OWSNavigationController(rootViewController: gifVC) + navController.modalPresentationStyle = .fullScreen + present(navController, animated: true) { } + } + + func gifPickerDidSelect(attachment: SignalAttachment) { + showAttachmentApprovalDialog(for: [ attachment ]) + } + + func handleDocumentButtonTapped() { + // UIDocumentPickerModeImport copies to a temp file within our container. + // It uses more memory than "open" but lets us avoid working with security scoped URLs. + let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import) + documentPickerVC.delegate = self + documentPickerVC.modalPresentationStyle = .fullScreen + SNAppearance.switchToDocumentPickerAppearance() + present(documentPickerVC, animated: true, completion: nil) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + SNAppearance.switchToSessionAppearance() + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + SNAppearance.switchToSessionAppearance() + guard let url = urls.first else { return } // TODO: Handle multiple? + let urlResourceValues: URLResourceValues + do { + urlResourceValues = try url.resourceValues(forKeys: [ .typeIdentifierKey, .isDirectoryKey, .nameKey ]) + } catch { + let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + return present(alert, animated: true, completion: nil) + } + let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String) + guard urlResourceValues.isDirectory != true else { + DispatchQueue.main.async { + let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE", comment: "") + let message = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY", comment: "") + OWSAlerts.showAlert(title: title, message: message) + } + return + } + let fileName = urlResourceValues.name ?? NSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "") + guard let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else { + DispatchQueue.main.async { + let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE", comment: "") + OWSAlerts.showAlert(title: title) + } + return + } + dataSource.sourceFilename = fileName + // Although we want to be able to send higher quality attachments through the document picker + // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) + guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: type) else { + return showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName) + } + // "Document picker" attachments _SHOULD NOT_ be resized + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: type, imageQuality: .original) + showAttachmentApprovalDialog(for: [ attachment ]) + } + + func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { + let navController = AttachmentApprovalViewController.wrappedInNavController(attachments: attachments, approvalDelegate: self) + present(navController, animated: true, completion: nil) + } + + func showAttachmentApprovalDialogAfterProcessingVideo(at url: URL, with fileName: String) { + ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self] modalActivityIndicator in + let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false)! + dataSource.sourceFilename = fileName + let compressionResult: SignalAttachment.VideoCompressionResult = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) + compressionResult.attachmentPromise.done { attachment in + guard !modalActivityIndicator.wasCancelled, let attachment = attachment as? SignalAttachment else { return } + modalActivityIndicator.dismiss { + if !attachment.hasError { + self?.showAttachmentApprovalDialog(for: [ attachment ]) + } else { + self?.showErrorAlert(for: attachment) + } + } + }.retainUntilComplete() + } + } + + // MARK: Message Sending + func handleSendButtonTapped() { + sendMessage() + } + + func sendMessage() { + guard !showBlockedModalIfNeeded() else { return } + let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) + let thread = self.thread + guard !text.isEmpty else { return } + let message = VisibleMessage() + message.sentTimestamp = NSDate.millisecondTimestamp() + message.text = text + message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model) + let linkPreviewDraft = snInputView.linkPreviewInfo?.draft + let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) + viewModel.appendUnsavedOutgoingTextMessage(tsMessage) + Storage.write(with: { transaction in + message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction) + }, completion: { [weak self] in + tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview) + Storage.shared.write { transaction in + tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction) + } + Storage.shared.write { transaction in + MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction) + } + self?.handleMessageSent() + }) + } + + func sendAttachments(_ attachments: [SignalAttachment], with text: String) { + guard !showBlockedModalIfNeeded() else { return } + for attachment in attachments { + if attachment.hasError { + return showErrorAlert(for: attachment) + } + } + let thread = self.thread + let message = VisibleMessage() + message.sentTimestamp = NSDate.millisecondTimestamp() + message.text = replaceMentions(in: text) + let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) + Storage.write(with: { transaction in + tsMessage.save(with: transaction) + }, completion: { [weak self] in + Storage.write { transaction in + MessageSender.send(message, with: attachments, in: thread, using: transaction) + } + self?.handleMessageSent() + }) + } + + func handleMessageSent() { + resetMentions() + self.snInputView.text = "" + self.snInputView.quoteDraftInfo = nil + self.markAllAsRead() + if Environment.shared.preferences.soundInForeground() { + let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true) + AudioServicesPlaySystemSound(soundID) + } + SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread) + } + + // MARK: Input View + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + let newText = inputTextView.text ?? "" + if !newText.isEmpty { + SSKEnvironment.shared.typingIndicators.didStartTypingOutgoingInput(inThread: thread) + } + updateMentions(for: newText) + } + + func showLinkPreviewSuggestionModal() { + let linkPreviewModel = LinkPreviewModal() { [weak self] in + self?.snInputView.autoGenerateLinkPreview() + } + linkPreviewModel.modalPresentationStyle = .overFullScreen + linkPreviewModel.modalTransitionStyle = .crossDissolve + present(linkPreviewModel, animated: true, completion: nil) + } + + // MARK: Mentions + func updateMentions(for newText: String) { + if newText.count < oldText.count { + currentMentionStartIndex = nil + snInputView.hideMentionsUI() + mentions = mentions.filter { $0.isContained(in: newText) } + } + if !newText.isEmpty { + let lastCharacterIndex = newText.index(before: newText.endIndex) + let lastCharacter = newText[lastCharacterIndex] + // Check if there is a whitespace before the '@' or the '@' is the first character + let isCharacterBeforeLastAtSignOrStartOfLine: Bool + if newText.count == 1 { + isCharacterBeforeLastAtSignOrStartOfLine = true // Start of line + } else { + let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)] + isCharacterBeforeLastAtSignOrStartOfLine = (characterBeforeLast == "@") + } + if lastCharacter == "@" && isCharacterBeforeLastAtSignOrStartOfLine { + let candidates = MentionsManager.getMentionCandidates(for: "", in: thread.uniqueId!) + currentMentionStartIndex = lastCharacterIndex + snInputView.showMentionsUI(for: candidates, in: thread) + } else if lastCharacter.isWhitespace { + currentMentionStartIndex = nil + snInputView.hideMentionsUI() + } else { + if let currentMentionStartIndex = currentMentionStartIndex { + let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ + let candidates = MentionsManager.getMentionCandidates(for: query, in: thread.uniqueId!) + snInputView.showMentionsUI(for: candidates, in: thread) + } + } + } + oldText = newText + } + + func resetMentions() { + oldText = "" + currentMentionStartIndex = nil + mentions = [] + } + + func replaceMentions(in text: String) -> String { + var result = text + for mention in mentions { + guard let range = result.range(of: "@\(mention.displayName)") else { continue } + result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)") + } + return result + } + + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { + guard let currentMentionStartIndex = currentMentionStartIndex else { return } + mentions.append(mention) + let oldText = snInputView.text + let newText = oldText.replacingCharacters(in: currentMentionStartIndex..., with: "@\(mention.displayName)") + snInputView.text = newText + self.currentMentionStartIndex = nil + snInputView.hideMentionsUI() + self.oldText = newText + } + + // MARK: View Item Interaction + func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { + guard let index = viewItems.firstIndex(where: { $0 === viewItem }), + let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil, + !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return } + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!) + let window = ContextMenuWindow() + let contextMenuVC = ContextMenuVC(snapshot: snapshot, viewItem: viewItem, frame: frame, delegate: self) { [weak self] in + window.isHidden = true + guard let self = self else { return } + self.contextMenuVC = nil + self.contextMenuWindow = nil + self.scrollButton.alpha = 0 + UIView.animate(withDuration: 0.25) { + self.scrollButton.alpha = self.getScrollButtonOpacity() + } + } + self.contextMenuVC = contextMenuVC + contextMenuWindow = window + window.rootViewController = contextMenuVC + window.makeKeyAndVisible() + window.backgroundColor = .clear + } + + func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) { + if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed { + showFailedMessageSheet(for: message) + } else { + switch viewItem.messageCellType { + case .audio: playOrPauseAudio(for: viewItem) + case .mediaMessage: + guard let index = viewItems.firstIndex(where: { $0 === viewItem }), + let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView = cell.albumView else { return } + let locationInCell = gestureRecognizer.location(in: cell) + if let overlayView = cell.mediaTextOverlayView { + let locationInOverlayView = cell.convert(locationInCell, to: overlayView) + if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) { + return showFullText(viewItem) // FIXME: Bit of a hack to do it this way + } + } + let locationInAlbumView = cell.convert(locationInCell, to: albumView) + guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } + if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() { + // TODO: Tapped a failed incoming attachment + } + let attachment = mediaView.attachment + if let pointer = attachment as? TSAttachmentPointer { + if pointer.state == .failed { + // TODO: Tapped a failed incoming attachment + } + } + guard let stream = attachment as? TSAttachmentStream else { return } + let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ]) + gallery.presentDetailView(fromViewController: self, mediaAttachment: stream, replacingView: mediaView) + case .genericAttachment: + guard let url = viewItem.attachmentStream?.originalMediaURL else { return } + let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil) + navigationController!.present(shareVC, animated: true, completion: nil) + case .textOnlyMessage: + if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) { + openURL(url) + } else if let reply = viewItem.quotedReply { + guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return } + messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true) + } + default: break + } + } + } + + func showFailedMessageSheet(for tsMessage: TSOutgoingMessage) { + let thread = self.thread + let sheet = UIAlertController(title: tsMessage.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in + Storage.write { transaction in + tsMessage.remove(with: transaction) + Storage.shared.cancelPendingMessageSendJobIfNeeded(for: tsMessage.timestamp, using: transaction) + } + })) + sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in + let message = VisibleMessage.from(tsMessage) + Storage.write { transaction in + var attachments: [TSAttachmentStream] = [] + tsMessage.attachmentIds.forEach { attachmentID in + guard let attachmentID = attachmentID as? String else { return } + let attachment = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) + guard let stream = attachment as? TSAttachmentStream else { return } + attachments.append(stream) + } + MessageSender.prep(attachments, for: message, using: transaction) + MessageSender.send(message, in: thread, using: transaction) + } + })) + present(sheet, animated: true, completion: nil) + } + + func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) { + switch viewItem.messageCellType { + case .audio: speedUpAudio(for: viewItem) + default: break + } + } + + func showFullText(_ viewItem: ConversationViewItem) { + let longMessageVC = LongTextViewController(viewItem: viewItem) + navigationController!.pushViewController(longMessageVC, animated: true) + } + + func reply(_ viewItem: ConversationViewItem) { + var quoteDraftOrNil: OWSQuotedReplyModel? + Storage.read { transaction in + quoteDraftOrNil = OWSQuotedReplyModel.quotedReplyForSending(with: viewItem, threadId: viewItem.interaction.uniqueThreadId, transaction: transaction) + } + guard let quoteDraft = quoteDraftOrNil else { return } + let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage) + snInputView.quoteDraftInfo = (model: quoteDraft, isOutgoing: isOutgoing) + snInputView.becomeFirstResponder() + } + + func copy(_ viewItem: ConversationViewItem) { + if viewItem.canCopyMedia() { + viewItem.copyMediaAction() + } else { + viewItem.copyTextAction() + } + } + + func copySessionID(_ viewItem: ConversationViewItem) { + guard let message = viewItem.interaction as? TSIncomingMessage else { return } + UIPasteboard.general.string = message.authorId + } + + func delete(_ viewItem: ConversationViewItem) { + viewItem.deleteAction() + } + + func save(_ viewItem: ConversationViewItem) { + guard viewItem.canSaveMedia() else { return } + viewItem.saveMediaAction() + } + + func ban(_ viewItem: ConversationViewItem) { + guard let message = viewItem.interaction as? TSIncomingMessage, message.isOpenGroupMessage else { return } + let alert = UIAlertController(title: "Ban This User?", message: nil, preferredStyle: .alert) + let threadID = thread.uniqueId! + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return } + let publicKey = message.authorId + OpenGroupAPI.ban(publicKey, from: openGroup.server).retainUntilComplete() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + present(alert, animated: true, completion: nil) + } + + func handleQuoteViewCancelButtonTapped() { + snInputView.quoteDraftInfo = nil + } + + func openURL(_ url: URL) { + let urlModal = URLModal(url: url) + urlModal.modalPresentationStyle = .overFullScreen + urlModal.modalTransitionStyle = .crossDissolve + present(urlModal, animated: true, completion: nil) + } + + func handleReplyButtonTapped(for viewItem: ConversationViewItem) { + reply(viewItem) + } + + // MARK: Voice Message Playback + @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) { + guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem, + let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return } + let nextViewItem = viewItems[index + 1] + guard nextViewItem.messageCellType == .audio else { return } + playOrPauseAudio(for: nextViewItem) + } + + func playOrPauseAudio(for viewItem: ConversationViewItem) { + guard let attachment = viewItem.attachmentStream else { return } + let fileManager = FileManager.default + guard let path = attachment.originalFilePath, fileManager.fileExists(atPath: path), + let url = attachment.originalMediaURL else { return } + if let audioPlayer = audioPlayer { + if let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem { + audioPlayer.playbackRate = 1 + audioPlayer.togglePlayState() + return + } else { + audioPlayer.stop() + self.audioPlayer = nil + } + } + let audioPlayer = OWSAudioPlayer(mediaUrl: url, audioBehavior: .audioMessagePlayback, delegate: viewItem) + self.audioPlayer = audioPlayer + audioPlayer.owner = viewItem + audioPlayer.play() + audioPlayer.setCurrentTime(Double(viewItem.audioProgressSeconds)) + } + + func speedUpAudio(for viewItem: ConversationViewItem) { + guard let audioPlayer = audioPlayer, let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem, audioPlayer.isPlaying else { return } + audioPlayer.playbackRate = 1.5 + viewItem.lastAudioMessageView?.showSpeedUpLabel() + } + + // MARK: Voice Message Recording + func startVoiceMessageRecording() { + // Request permission if needed + requestMicrophonePermissionIfNeeded() { [weak self] in + self?.cancelVoiceMessageRecording() + } + guard AVAudioSession.sharedInstance().recordPermission == .granted else { return } + // Cancel any current audio playback + audioPlayer?.stop() + audioPlayer = nil + // Create URL + let directory = OWSTemporaryDirectory() + let fileName = "\(NSDate.millisecondTimestamp()).m4a" + let path = (directory as NSString).appendingPathComponent(fileName) + let url = URL(fileURLWithPath: path) + // Set up audio session + let isConfigured = audioSession.startAudioActivity(recordVoiceMessageActivity) + guard isConfigured else { + return cancelVoiceMessageRecording() + } + // Set up audio recorder + let settings: [String:NSNumber] = [ + AVFormatIDKey : NSNumber(value: kAudioFormatMPEG4AAC), + AVSampleRateKey : NSNumber(value: 44100), + AVNumberOfChannelsKey : NSNumber(value: 2), + AVEncoderBitRateKey : NSNumber(value: 128 * 1024) + ] + let audioRecorder: AVAudioRecorder + do { + audioRecorder = try AVAudioRecorder(url: url, settings: settings) + audioRecorder.isMeteringEnabled = true + self.audioRecorder = audioRecorder + } catch { + SNLog("Couldn't start audio recording due to error: \(error).") + return cancelVoiceMessageRecording() + } + // Limit voice messages to a minute + audioTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false, block: { [weak self] _ in + self?.snInputView.hideVoiceMessageUI() + self?.endVoiceMessageRecording() + }) + // Prepare audio recorder + guard audioRecorder.prepareToRecord() else { + SNLog("Couldn't prepare audio recorder.") + return cancelVoiceMessageRecording() + } + // Start recording + guard audioRecorder.record() else { + SNLog("Couldn't record audio.") + return cancelVoiceMessageRecording() + } + } + + func endVoiceMessageRecording() { + // Hide the UI + snInputView.hideVoiceMessageUI() + // Cancel the timer + audioTimer?.invalidate() + // Check preconditions + guard let audioRecorder = audioRecorder else { return } + // Get duration + let duration = audioRecorder.currentTime + // Stop the recording + stopVoiceMessageRecording() + // Check for user misunderstanding + guard duration > 1 else { + self.audioRecorder = nil + let title = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "") + let message = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "") + return OWSAlerts.showAlert(title: title, message: message) + } + // Get data + let dataSourceOrNil = DataSourcePath.dataSource(with: audioRecorder.url, shouldDeleteOnDeallocation: true) + self.audioRecorder = nil + guard let dataSource = dataSourceOrNil else { return SNLog("Couldn't load recorded data.") } + // Create attachment + let fileName = (NSLocalizedString("VOICE_MESSAGE_FILE_NAME", comment: "") as NSString).appendingPathExtension("m4a") + dataSource.sourceFilename = fileName + let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String) + guard !attachment.hasError else { + return showErrorAlert(for: attachment) + } + // Send attachment + sendAttachments([ attachment ], with: "") + } + + func cancelVoiceMessageRecording() { + snInputView.hideVoiceMessageUI() + audioTimer?.invalidate() + stopVoiceMessageRecording() + audioRecorder = nil + } + + func stopVoiceMessageRecording() { + audioRecorder?.stop() + audioSession.endAudioActivity(recordVoiceMessageActivity) + } + + // MARK: Requesting Permission + func requestCameraPermissionIfNeeded() -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: return true + case .denied, .restricted: + let modal = PermissionMissingModal(permission: "camera") { } + modal.modalPresentationStyle = .overFullScreen + modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + return false + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in }) + return false + default: return false + } + } + + func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) { + switch AVAudioSession.sharedInstance().recordPermission { + case .granted: break + case .denied: + onNotGranted() + let modal = PermissionMissingModal(permission: "microphone") { + onNotGranted() + } + modal.modalPresentationStyle = .overFullScreen + modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + case .undetermined: + onNotGranted() + AVAudioSession.sharedInstance().requestRecordPermission { _ in } + default: break + } + } + + func requestLibraryPermissionIfNeeded() -> Bool { + switch PHPhotoLibrary.authorizationStatus() { + case .authorized, .limited: return true + case .denied, .restricted: + let modal = PermissionMissingModal(permission: "library") { } + modal.modalPresentationStyle = .overFullScreen + modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + return false + case .notDetermined: + PHPhotoLibrary.requestAuthorization { _ in } + return false + default: return false + } + } + + // MARK: Convenience + func showErrorAlert(for attachment: SignalAttachment) { + let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "") + let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage + OWSAlerts.showAlert(title: title, message: message) + } +} diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift new file mode 100644 index 000000000..f49bec425 --- /dev/null +++ b/Session/Conversations/ConversationVC.swift @@ -0,0 +1,494 @@ + +// TODO +// • Slight paging glitch +// • Image detail VC transition glitch +// • Photo rounding +// • Scroll button behind mentions view +// • Remaining search glitchiness + +final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + let thread: TSThread + let focusedMessageID: String? + var didConstrainScrollButton = false + // Search + var isShowingSearchUI = false + var lastSearchedText: String? + // Audio playback & recording + var audioPlayer: OWSAudioPlayer? + var audioRecorder: AVAudioRecorder? + var audioTimer: Timer? + // Context menu + var contextMenuWindow: ContextMenuWindow? + var contextMenuVC: ContextMenuVC? + // Mentions + var oldText = "" + var currentMentionStartIndex: String.Index? + var mentions: [Mention] = [] + // Scrolling & paging + var isUserScrolling = false + var didFinishInitialLayout = false + var isLoadingMore = false + var scrollDistanceToBottomBeforeUpdate: CGFloat? + + var audioSession: OWSAudioSession { Environment.shared.audioSession } + var dbConnection: YapDatabaseConnection { OWSPrimaryStorage.shared().uiDatabaseConnection } + var viewItems: [ConversationViewItem] { viewModel.viewState.viewItems } + override var inputAccessoryView: UIView? { isShowingSearchUI ? searchController.resultsBar : snInputView } + override var canBecomeFirstResponder: Bool { true } + + var tableViewUnobscuredHeight: CGFloat { + let bottomInset = messagesTableView.adjustedContentInset.bottom + return messagesTableView.bounds.height - bottomInset + } + + var lastPageTop: CGFloat { + return messagesTableView.contentSize.height - tableViewUnobscuredHeight + } + + lazy var viewModel = ConversationViewModel(thread: thread, focusMessageIdOnOpen: focusedMessageID, delegate: self) + + lazy var mediaCache: NSCache = { + let result = NSCache() + result.countLimit = 40 + return result + }() + + lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord) + + lazy var searchController: ConversationSearchController = { + let result = ConversationSearchController(thread: thread) + result.delegate = self + if #available(iOS 13, *) { + result.uiSearchController.obscuresBackgroundDuringPresentation = false + } else { + result.uiSearchController.dimsBackgroundDuringPresentation = false + } + return result + }() + + // MARK: UI Components + lazy var titleView = ConversationTitleView(thread: thread) + + lazy var messagesTableView: MessagesTableView = { + let result = MessagesTableView() + result.dataSource = self + result.delegate = self + return result + }() + + lazy var snInputView = InputView(delegate: self) + + lazy var scrollButton = ScrollToBottomButton(delegate: self) + + lazy var blockedBanner: InfoBanner = { + let name: String + if let thread = thread as? TSContactThread { + let publicKey = thread.contactIdentifier() + name = OWSProfileManager.shared().profileNameForRecipient(withID: publicKey, avoidingWriteTransaction: true) ?? publicKey + } else { + name = "Thread" + } + let message = "\(name) is blocked. Unblock them?" + let result = InfoBanner(message: message, backgroundColor: Colors.destructive) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock)) + result.addGestureRecognizer(tapGestureRecognizer) + return result + }() + + // MARK: Settings + static let bottomInset = Values.mediumSpacing + static let loadMoreThreshold: CGFloat = 120 + /// The button will be fully visible once the user has scrolled this amount from the bottom of the table view. + static let scrollButtonFullVisibilityThreshold: CGFloat = 80 + /// The button will be invisible until the user has scrolled at least this amount from the bottom of the table view. + static let scrollButtonNoVisibilityThreshold: CGFloat = 20 + + // MARK: Lifecycle + init(thread: TSThread, focusedMessageID: String? = nil) { + self.thread = thread + self.focusedMessageID = focusedMessageID + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(thread:) instead.") + } + + override func viewDidLoad() { + super.viewDidLoad() + // Gradient + setUpGradientBackground() + // Nav bar + setUpNavBarStyle() + navigationItem.titleView = titleView + updateNavBarButtons() + // Constraints + view.addSubview(messagesTableView) + messagesTableView.pin(to: view) + view.addSubview(scrollButton) + scrollButton.pin(.right, to: .right, of: view, withInset: -22) + // Blocked banner + addOrRemoveBlockedBanner() + // Notifications + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(handleAudioDidFinishPlayingNotification(_:)), name: .SNAudioDidFinishPlaying, object: nil) + notificationCenter.addObserver(self, selector: #selector(addOrRemoveBlockedBanner), name: NSNotification.Name(rawValue: kNSNotificationName_BlockListDidChange), object: nil) + // Mentions + MentionsManager.populateUserPublicKeyCacheIfNeeded(for: thread.uniqueId!) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if !didFinishInitialLayout { + DispatchQueue.main.async { + self.scrollToBottom(isAnimated: false) + } + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + didFinishInitialLayout = true + markAllAsRead() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + mediaCache.removeAllObjects() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: Table View Data Source + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let viewItem = viewItems[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: MessageCell.getCellType(for: viewItem).identifier) as! MessageCell + cell.delegate = self + cell.viewItem = viewItem + return cell + } + + // MARK: Updating + func updateNavBarButtons() { + navigationItem.hidesBackButton = isShowingSearchUI + if isShowingSearchUI { + navigationItem.rightBarButtonItems = [] + } else { + let rightBarButtonItem: UIBarButtonItem + if thread is TSContactThread { + let size = Values.verySmallProfilePictureSize + let profilePictureView = ProfilePictureView() + profilePictureView.accessibilityLabel = "Settings button" + profilePictureView.size = size + profilePictureView.update(for: thread) + profilePictureView.set(.width, to: size) + profilePictureView.set(.height, to: size) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) + profilePictureView.addGestureRecognizer(tapGestureRecognizer) + rightBarButtonItem = UIBarButtonItem(customView: profilePictureView) + } else { + rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) + } + rightBarButtonItem.accessibilityLabel = "Settings button" + rightBarButtonItem.isAccessibilityElement = true + navigationItem.rightBarButtonItem = rightBarButtonItem + } + } + + @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { + guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } + if !didConstrainScrollButton { + // Bit of a hack to do this here, but it works out. + scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -(newHeight + 22)) + didConstrainScrollButton = true + } + UIView.animate(withDuration: 0.25) { + self.messagesTableView.keyboardHeight = newHeight + self.scrollButton.alpha = 0 + } + } + + @objc func handleKeyboardWillHideNotification(_ notification: Notification) { + UIView.animate(withDuration: 0.25) { + self.messagesTableView.keyboardHeight = 0 + self.scrollButton.alpha = self.getScrollButtonOpacity() + } + } + + func conversationViewModelWillUpdate() { + + } + + func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) { + guard self.isViewLoaded else { return } + // TODO: Reload the thread if it's a group thread? + let updateType = conversationUpdate.conversationUpdateType + guard updateType != .minor else { return } // No view items were affected + if updateType == .reload { + return messagesTableView.reloadData() + } + var shouldScrollToBottom = false + let shouldAnimate = conversationUpdate.shouldAnimateUpdates + let batchUpdates: () -> Void = { + for update in conversationUpdate.updateItems! { + switch update.updateItemType { + case .delete: + self.messagesTableView.deleteRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .fade) + case .insert: + // Perform inserts before updates + self.messagesTableView.insertRows(at: [ IndexPath(row: Int(update.newIndex), section: 0) ], with: .fade) + let viewItem = update.viewItem + if viewItem?.interaction is TSOutgoingMessage { + shouldScrollToBottom = true + } + case .update: + self.messagesTableView.reloadRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .fade) + default: preconditionFailure() + } + } + } + let batchUpdatesCompletion: (Bool) -> Void = { isFinished in + // TODO: Update last visible sort ID? + if shouldScrollToBottom { + self.scrollToBottom(isAnimated: true) + } + // TODO: Update last known distance from bottom + } + if shouldAnimate { + messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion) + } else { + // HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a + // UIKit Crash like: + // + // *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:], + // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760 + // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While + // preparing update a visible view at {length = 2, path = 0 - 142} + // wasn't found in the current data model and was not in an update animation. This is an internal + // error.' + // + // I'm unclear if this is a bug in UIKit, or if we're doing something crazy in + // ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the + // conversation. + UIView.animate(withDuration: 0) { + self.messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion) + if shouldScrollToBottom { + self.scrollToBottom(isAnimated: false) + } + } + } + // TODO: Set last reload date? + } + + func conversationViewModelWillLoadMoreItems() { + view.layoutIfNeeded() + scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y + } + + func conversationViewModelDidLoadMoreItems() { + guard let scrollDistanceToBottomBeforeUpdate = scrollDistanceToBottomBeforeUpdate else { return } + view.layoutIfNeeded() + messagesTableView.contentOffset.y = messagesTableView.contentSize.height - scrollDistanceToBottomBeforeUpdate + isLoadingMore = false + } + + func conversationViewModelDidLoadPrevPage() { + + } + + func conversationViewModelRangeDidChange() { + + } + + func conversationViewModelDidReset() { + + } + + // MARK: General + @objc func addOrRemoveBlockedBanner() { + func detach() { + blockedBanner.removeFromSuperview() + } + guard let thread = thread as? TSContactThread else { return detach() } + if OWSBlockingManager.shared().isRecipientIdBlocked(thread.contactIdentifier()) { + view.addSubview(blockedBanner) + blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view) + } else { + detach() + } + } + + func markAllAsRead() { + guard let lastSortID = viewItems.last?.interaction.sortId else { return } + OWSReadReceiptManager.shared().markAsReadLocally(beforeSortId: lastSortID, thread: thread) + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + func getMediaCache() -> NSCache { + return mediaCache + } + + func scrollToBottom(isAnimated: Bool) { + guard !isUserScrolling else { return } + // Ensure the view is fully up to date before we try to scroll to the bottom, since + // we use the table view's bounds to determine where the bottom is. + view.layoutIfNeeded() + let firstContentPageTop: CGFloat = 0 + let contentOffsetY = max(firstContentPageTop, lastPageTop) + messagesTableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: isAnimated) + // TODO: Did scroll to bottom + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isUserScrolling = true + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + isUserScrolling = false + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + scrollButton.alpha = getScrollButtonOpacity() + autoLoadMoreIfNeeded() + } + + func autoLoadMoreIfNeeded() { + let isMainAppAndActive = CurrentAppContext().isMainAppAndActive + guard isMainAppAndActive && viewModel.canLoadMoreItems() && !isLoadingMore + && messagesTableView.contentOffset.y < ConversationVC.loadMoreThreshold else { return } + isLoadingMore = true + viewModel.loadAnotherPageOfMessages() + } + + func getScrollButtonOpacity() -> CGFloat { + let contentOffsetY = messagesTableView.contentOffset.y + let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) + let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold) + return a * x + } + + func groupWasUpdated(_ groupModel: TSGroupModel) { + // Do nothing + } + + // MARK: Search + func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) { + showSearchUI() + popAllConversationSettingsViews { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.searchController.uiSearchController.searchBar.becomeFirstResponder() + } + } + } + + func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) { + if presentedViewController != nil { + dismiss(animated: true) { + self.navigationController!.popToViewController(self, animated: true, completion: completionBlock) + } + } else { + navigationController!.popToViewController(self, animated: true, completion: completionBlock) + } + } + + func showSearchUI() { + isShowingSearchUI = true + // Search bar + let searchBar = searchController.uiSearchController.searchBar + searchBar.searchBarStyle = .minimal + searchBar.barStyle = .black + searchBar.tintColor = Colors.accent + let searchIcon = UIImage(named: "searchbar_search")!.asTintedImage(color: Colors.searchBarPlaceholder) + searchBar.setImage(searchIcon, for: .search, state: UIControl.State.normal) + let clearIcon = UIImage(named: "searchbar_clear")!.asTintedImage(color: Colors.searchBarPlaceholder) + searchBar.setImage(clearIcon, for: .clear, state: UIControl.State.normal) + let searchTextField: UITextField + if #available(iOS 13, *) { + searchTextField = searchBar.searchTextField + } else { + searchTextField = searchBar.value(forKey: "_searchField") as! UITextField + } + searchTextField.backgroundColor = Colors.searchBarBackground + searchTextField.textColor = Colors.text + searchTextField.attributedPlaceholder = NSAttributedString(string: "Search", attributes: [ .foregroundColor : Colors.searchBarPlaceholder ]) + searchTextField.keyboardAppearance = isLightMode ? .default : .dark + searchBar.setPositionAdjustment(UIOffset(horizontal: 4, vertical: 0), for: .search) + searchBar.searchTextPositionAdjustment = UIOffset(horizontal: 2, vertical: 0) + searchBar.setPositionAdjustment(UIOffset(horizontal: -4, vertical: 0), for: .clear) + navigationItem.titleView = searchBar + // Nav bar buttons + updateNavBarButtons() + // Hack so that the ResultsBar stays on the screen when dismissing the search field + // keyboard. + // + // Details: + // + // When the search UI is activated, both the SearchField and the ConversationVC + // have the resultsBar as their inputAccessoryView. + // + // So when the SearchField is first responder, the ResultsBar is shown on top of the keyboard. + // When the ConversationVC is first responder, the ResultsBar is shown at the bottom of the + // screen. + // + // When the user swipes to dismiss the keyboard, trying to see more of the content while + // searching, we want the ResultsBar to stay at the bottom of the screen - that is, we + // want the ConversationVC to becomeFirstResponder. + // + // If the SearchField were a subview of ConversationVC.view, this would all be automatic, + // as first responder status is percolated up the responder chain via `nextResponder`, which + // basically travereses each superView, until you're at a rootView, at which point the next + // responder is the ViewController which controls that View. + // + // However, because SearchField lives in the Navbar, it's "controlled" by the + // NavigationController, not the ConversationVC. + // + // So here we stub the next responder on the navBar so that when the searchBar resigns + // first responder, the ConversationVC will be in it's responder chain - keeeping the + // ResultsBar on the bottom of the screen after dismissing the keyboard. + let navBar = navigationController!.navigationBar as! OWSNavigationBar + navBar.stubbedNextResponder = self + } + + func hideSearchUI() { + isShowingSearchUI = false + navigationItem.titleView = titleView + updateNavBarButtons() + let navBar = navigationController!.navigationBar as! OWSNavigationBar + navBar.stubbedNextResponder = nil + becomeFirstResponder() + reloadInputViews() + } + + func didDismissSearchController(_ searchController: UISearchController) { + hideSearchUI() + } + + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) { + lastSearchedText = resultSet?.searchText + messagesTableView.reloadRows(at: messagesTableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) + } + + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectMessageId interactionID: String) { + scrollToInteraction(with: interactionID) + } + + func scrollToInteraction(with interactionID: String) { + guard let indexPath = viewModel.ensureLoadWindowContainsInteractionId(interactionID) else { return } + messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true) + } +} diff --git a/Session/Conversations/ConversationViewAction.h b/Session/Conversations/ConversationViewAction.h new file mode 100644 index 000000000..17d7cc29a --- /dev/null +++ b/Session/Conversations/ConversationViewAction.h @@ -0,0 +1,8 @@ +@import Foundation; + +typedef NS_ENUM(NSUInteger, ConversationViewAction) { + ConversationViewActionNone, + ConversationViewActionCompose, + ConversationViewActionAudioCall, + ConversationViewActionVideoCall, +}; diff --git a/Session/Conversations/ConversationViewController.h b/Session/Conversations/ConversationViewController.h deleted file mode 100644 index 996ed10ca..000000000 --- a/Session/Conversations/ConversationViewController.h +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSUInteger, ConversationViewAction) { - ConversationViewActionNone, - ConversationViewActionCompose, - ConversationViewActionAudioCall, - ConversationViewActionVideoCall, -}; - -@class TSThread; - -@interface ConversationViewController : OWSViewController - -@property (nonatomic, readonly) TSThread *thread; - -- (void)configureForThread:(TSThread *)thread - action:(ConversationViewAction)action - focusMessageId:(nullable NSString *)focusMessageId; - -- (void)popKeyBoard; - -- (void)scrollToFirstUnreadMessage:(BOOL)isAnimated; - -#pragma mark 3D Touch Methods - -- (void)peekSetup; -- (void)popped; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewController.m b/Session/Conversations/ConversationViewController.m deleted file mode 100644 index b7f2b3a53..000000000 --- a/Session/Conversations/ConversationViewController.m +++ /dev/null @@ -1,4508 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewController.h" -#import "AppDelegate.h" -#import -#import "ConversationCollectionView.h" -#import "ConversationInputTextView.h" -#import "ConversationInputToolbar.h" -#import "ConversationScrollButton.h" -#import "ConversationViewCell.h" -#import "ConversationViewItem.h" -#import "ConversationViewLayout.h" -#import "ConversationViewModel.h" -#import "DateUtil.h" -#import -#import "OWSAudioPlayer.h" -#import "OWSConversationSettingsViewController.h" -#import "OWSConversationSettingsViewDelegate.h" -#import "OWSDisappearingMessagesJob.h" -#import "OWSMath.h" -#import "OWSMessageCell.h" -#import "OWSSystemMessageCell.h" -#import -#import "Session-Swift.h" -#import "TSAttachmentPointer.h" -#import "TSContactThread.h" -#import "TSDatabaseView.h" -#import "TSErrorMessage.h" -#import "TSGroupThread.h" -#import "TSIncomingMessage.h" -#import "TSInfoMessage.h" -#import "UIFont+OWS.h" -#import "UIViewController+Permissions.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -@import Photos; - -NS_ASSUME_NONNULL_BEGIN - -static const CGFloat kLoadMoreHeaderHeight = 60.f; - -static const CGFloat kToastInset = 10; - -typedef enum : NSUInteger { - kMediaTypePicture, - kMediaTypeVideo, -} kMediaTypes; - -typedef enum : NSUInteger { - kScrollContinuityBottom = 0, - kScrollContinuityTop, -} ScrollContinuity; - -#pragma mark - - -@interface ConversationViewController () - -@property (nonatomic) TSThread *thread; -@property (nonatomic, readonly) ConversationViewModel *conversationViewModel; - -@property (nonatomic, readonly) OWSAudioActivity *recordVoiceNoteAudioActivity; -@property (nonatomic, readonly) NSTimeInterval viewControllerCreatedAt; - -@property (nonatomic, readonly) ConversationInputToolbar *inputToolbar; -@property (nonatomic, readonly) ConversationCollectionView *collectionView; -@property (nonatomic, readonly) UIProgressView *progressIndicatorView; -@property (nonatomic, readonly) ConversationViewLayout *layout; -@property (nonatomic, readonly) ConversationStyle *conversationStyle; - -@property (nonatomic, nullable) AVAudioRecorder *audioRecorder; -@property (nonatomic, nullable) NSTimer *audioTimer; -@property (nonatomic, nullable) OWSAudioPlayer *audioAttachmentPlayer; -@property (nonatomic, nullable) NSUUID *voiceMessageUUID; - -@property (nonatomic, nullable) NSTimer *readTimer; -@property (nonatomic) NSCache *cellMediaCache; -@property (nonatomic) LKConversationTitleView *headerView; -@property (nonatomic, nullable) UIView *bannerView; -@property (nonatomic, nullable) UIView *restoreSessionBannerView; -@property (nonatomic, nullable) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration; - -// Back Button Unread Count -@property (nonatomic, readonly) UIView *backButtonUnreadCountView; -@property (nonatomic, readonly) UILabel *backButtonUnreadCountLabel; -@property (nonatomic, readonly) NSUInteger backButtonUnreadCount; - -@property (nonatomic) ConversationViewAction actionOnOpen; - -@property (nonatomic) BOOL peek; - -@property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper; - -@property (nonatomic) BOOL userHasScrolled; -@property (nonatomic, nullable) NSDate *lastMessageSentDate; - -@property (nonatomic, nullable) UIBarButtonItem *customBackButton; - -@property (nonatomic) BOOL showLoadMoreHeader; -@property (nonatomic) UILabel *loadMoreHeader; -@property (nonatomic) uint64_t lastVisibleSortId; - -@property (nonatomic) BOOL isUserScrolling; - -@property (nonatomic) NSLayoutConstraint *scrollDownButtonButtomConstraint; - -@property (nonatomic) ConversationScrollButton *scrollDownButton; - -@property (nonatomic) BOOL isViewCompletelyAppeared; -@property (nonatomic) BOOL isViewVisible; -@property (nonatomic) BOOL shouldAnimateKeyboardChanges; -@property (nonatomic) BOOL viewHasEverAppeared; -@property (nonatomic) BOOL hasUnreadMessages; -@property (nonatomic) BOOL isPickingMediaAsDocument; -@property (nonatomic, nullable) NSNumber *viewHorizonTimestamp; -@property (nonatomic) NSTimer *reloadTimer; -@property (nonatomic, nullable) NSDate *lastReloadDate; - -@property (nonatomic) CGFloat scrollDistanceToBottomSnapshot; -@property (nonatomic, nullable) NSNumber *lastKnownDistanceFromBottom; -@property (nonatomic) ScrollContinuity scrollContinuity; -@property (nonatomic, nullable) NSTimer *autoLoadMoreTimer; - -@property (nonatomic, readonly) ConversationSearchController *searchController; -@property (nonatomic, nullable) NSString *lastSearchedText; -@property (nonatomic) BOOL isShowingSearchUI; -@property (nonatomic, nullable) MenuActionsViewController *menuActionsViewController; -@property (nonatomic) CGFloat extraContentInsetPadding; -@property (nonatomic) CGFloat contentInsetBottom; - -// Mentions -@property (nonatomic) NSInteger currentMentionStartIndex; -@property (nonatomic) NSMutableArray *mentions; -@property (nonatomic) NSString *oldText; - -// Status bar updating -/// Used to avoid duplicate status bar updates. -@property (nonatomic) NSMutableSet *handledMessageTimestamps; - -@end - -#pragma mark - - -@implementation ConversationViewController - -- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder -{ - OWSFailDebug(@"Do not instantiate this view from coder"); - - self = [super initWithCoder:aDecoder]; - if (!self) { - return self; - } - - [self commonInit]; - - return self; -} - -- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil -{ - self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; - if (!self) { - return self; - } - - [self commonInit]; - - return self; -} - -- (void)commonInit -{ - _viewControllerCreatedAt = CACurrentMediaTime(); - - NSString *audioActivityDescription = [NSString stringWithFormat:@"%@ voice note", self.logTag]; - _recordVoiceNoteAudioActivity = [[OWSAudioActivity alloc] initWithAudioDescription:audioActivityDescription behavior:OWSAudioBehavior_PlayAndRecord]; - - self.scrollContinuity = kScrollContinuityBottom; - - _currentMentionStartIndex = -1; - _mentions = [NSMutableArray new]; - _oldText = @""; -} - -#pragma mark - Dependencies - -- (OWSAudioSession *)audioSession -{ - return Environment.shared.audioSession; -} - -- (OWSBlockingManager *)blockingManager -{ - return [OWSBlockingManager sharedManager]; -} - -- (OWSPrimaryStorage *)primaryStorage -{ - return SSKEnvironment.shared.primaryStorage; -} - -- (id)typingIndicators -{ - return SSKEnvironment.shared.typingIndicators; -} - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -- (OWSNotificationPresenter *)notificationPresenter -{ - return AppEnvironment.shared.notificationPresenter; -} - -#pragma mark - - -- (void)addNotificationListeners -{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(blockListDidChange:) - name:kNSNotificationName_BlockListDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowManagerCallDidChange:) - name:OWSWindowManagerCallDidChangeNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(identityStateDidChange:) - name:kNSNotificationName_IdentityStateDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(didChangePreferredContentSize:) - name:UIContentSizeCategoryDidChangeNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillEnterForeground:) - name:OWSApplicationWillEnterForegroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:OWSApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:OWSApplicationWillResignActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(cancelReadTimer) - name:OWSApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_OtherUsersProfileDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(profileWhitelistDidChange:) - name:kNSNotificationName_ProfileWhitelistDidChange - object:nil]; - // Keyboard events. - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(keyboardWillShow:) - name:UIKeyboardWillShowNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(keyboardDidShow:) - name:UIKeyboardDidShowNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(keyboardWillHide:) - name:UIKeyboardWillHideNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(keyboardDidHide:) - name:UIKeyboardDidHideNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(keyboardWillChangeFrame:) - name:UIKeyboardWillChangeFrameNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(keyboardDidChangeFrame:) - name:UIKeyboardDidChangeFrameNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleThreadSessionRestoreDevicesChangedNotifiaction:) - name:NSNotification.threadSessionRestoreDevicesChanged - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleGroupThreadUpdatedNotification:) - name:NSNotification.groupThreadUpdated - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleEncryptingMessageNotification:) - name:NSNotification.encryptingMessage - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleCalculatingMessagePoWNotification:) - name:NSNotification.calculatingMessagePoW - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleMessageSendingNotification:) - name:NSNotification.messageSending - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleMessageSentNotification:) - name:NSNotification.messageSent - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleMessageSendingFailedNotification:) - name:NSNotification.messageSendingFailed - object:nil]; -} - -- (BOOL)isGroupConversation -{ - OWSAssertDebug(self.thread); - - return self.thread.isGroupThread; -} - - -- (void)otherUsersProfileDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; - OWSAssertDebug(recipientId.length > 0); - if (recipientId.length > 0 && [self.thread.recipientIdentifiers containsObject:recipientId]) { - - if (self.isGroupConversation) { - // Reload all cells if this is a group conversation, - // since we may need to update the sender names on the messages. - [self resetContentAndLayout]; - } - } -} - -- (void)profileWhitelistDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - // If profile whitelist just changed, we may want to hide a profile whitelist offer. - NSString *_Nullable recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; - NSData *_Nullable groupId = notification.userInfo[kNSNotificationKey_ProfileGroupId]; - if (recipientId.length > 0 && [self.thread.recipientIdentifiers containsObject:recipientId]) { - [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; - } else if (groupId.length > 0 && self.thread.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - if ([groupThread.groupModel.groupId isEqualToData:groupId]) { - [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; - [self ensureBannerState]; - } - } -} - -- (void)blockListDidChange:(id)notification -{ - OWSAssertIsOnMainThread(); - - [self ensureBannerState]; -} - -- (void)identityStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self ensureBannerState]; -} - -- (void)handleGroupThreadUpdatedNotification:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - // Check thread - NSString *threadID = (NSString *)notification.object; - if (![threadID isEqualToString:self.thread.uniqueId]) { return; } - // Ensure thread instance is up to date - [self.thread reload]; - // Update UI - [self hideInputIfNeeded]; - [self.collectionView.collectionViewLayout invalidateLayout]; - for (id item in self.viewItems) { - [item clearCachedLayoutState]; - } - [self.conversationViewModel reloadViewItems]; - [self.collectionView reloadData]; -} - -- (void)handleThreadSessionRestoreDevicesChangedNotifiaction:(NSNotification *)notification -{ - // Check thread - NSString *threadID = (NSString *)notification.object; - if (![threadID isEqualToString:self.thread.uniqueId]) { return; } - // Ensure thread instance is up to date - [self.thread reload]; - // Update UI - [self updateSessionRestoreBanner]; -} - -- (void)peekSetup -{ - _peek = YES; - self.actionOnOpen = ConversationViewActionNone; -} - -- (void)popped -{ - _peek = NO; - [self hideInputIfNeeded]; -} - -- (void)configureForThread:(TSThread *)thread - action:(ConversationViewAction)action - focusMessageId:(nullable NSString *)focusMessageId -{ - OWSAssertDebug(thread); - - OWSLogInfo(@"configureForThread."); - - _thread = thread; - self.actionOnOpen = action; - _cellMediaCache = [NSCache new]; - // Cache the cell media for ~24 cells. - self.cellMediaCache.countLimit = 24; - _conversationStyle = [[ConversationStyle alloc] initWithThread:thread]; - - _conversationViewModel = - [[ConversationViewModel alloc] initWithThread:thread focusMessageIdOnOpen:focusMessageId delegate:self]; - - _searchController = [[ConversationSearchController alloc] initWithThread:thread]; - _searchController.delegate = self; - - self.reloadTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f - target:self - selector:@selector(reloadTimerDidFire) - userInfo:nil - repeats:YES]; - - [LKMentionsManager populateUserPublicKeyCacheIfNeededFor:thread.uniqueId in:nil]; -} - -- (void)dealloc -{ - [self.reloadTimer invalidate]; - [self.autoLoadMoreTimer invalidate]; -} - -- (void)reloadTimerDidFire -{ - OWSAssertIsOnMainThread(); - - if (self.isUserScrolling || !self.isViewCompletelyAppeared || !self.isViewVisible - || !CurrentAppContext().isAppForegroundAndActive || !self.viewHasEverAppeared - || OWSWindowManager.sharedManager.isPresentingMenuActions) { - return; - } - - NSDate *now = [NSDate new]; - if (self.lastReloadDate) { - NSTimeInterval timeSinceLastReload = [now timeIntervalSinceDate:self.lastReloadDate]; - const NSTimeInterval kReloadFrequency = 60.f; - if (timeSinceLastReload < kReloadFrequency) { - return; - } - } - - OWSLogVerbose(@"reloading conversation view contents."); - [self resetContentAndLayout]; -} - -- (BOOL)userLeftGroup -{ - if (![_thread isKindOfClass:[TSGroupThread class]]) { - return NO; - } - - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - return !groupThread.isCurrentUserMemberInGroup; -} - -- (void)hideInputIfNeeded -{ - if (_peek) { - self.inputToolbar.hidden = YES; - [self dismissKeyBoard]; - return; - } - - if ([self.thread isKindOfClass:TSGroupThread.class] && !((TSGroupThread *)self.thread).isOpenGroup - && !((TSGroupThread *)self.thread).isClosedGroup) { - self.inputToolbar.hidden = YES; - } else if (self.userLeftGroup) { - self.inputToolbar.hidden = YES; // user has requested they leave the group. further sends disallowed - [self dismissKeyBoard]; - } else { - self.inputToolbar.hidden = NO; - } -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - [self createContents]; - - [self createConversationScrollButtons]; - [self createHeaderViews]; - - if (@available(iOS 11, *)) { - // We use the default back button from home view, which animates nicely with interactive transitions like the - // interactive pop gesture and the "slide left" for info. - } else { - // On iOS9/10 the default back button is too wide, so we use a custom back button. This doesn't animate nicely - // with interactive transitions, but has the appropriate width. - [self createBackButton]; - } - - [self addNotificationListeners]; - [self loadDraftInCompose]; - [self applyTheme]; - [self.conversationViewModel viewDidLoad]; - - [LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:nil customBackButton:YES]; - self.collectionView.backgroundColor = UIColor.clearColor; - UIBarButtonItem *settingsButton = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"Gear"] style:UIBarButtonItemStylePlain target:self action:@selector(showConversationSettings)]; - settingsButton.tintColor = LKColors.text; - settingsButton.accessibilityLabel = @"Conversation settings button"; - settingsButton.isAccessibilityElement = YES; - self.navigationItem.rightBarButtonItem = settingsButton; - - if (self.thread.isGroupThread) { - TSGroupThread *thread = (TSGroupThread *)self.thread; - if (!thread.isOpenGroup) { return; } - SNOpenGroup *publicChat = [LKStorage.shared getOpenGroupForThreadID:thread.uniqueId]; - [SNOpenGroupAPI getInfoForChannelWithID:publicChat.channel onServer:publicChat.server] - .thenOn(dispatch_get_main_queue(), ^(id userCount) { - [self.headerView updateSubtitleForCurrentStatus]; - }); - } - - if ([self.thread isKindOfClass:TSContactThread.class]) { - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [SSKEnvironment.shared.profileManager ensureProfileCachedForContactWithID:self.thread.contactIdentifier with:transaction]; - }]; - } -} - -- (void)createContents -{ - OWSAssertDebug(self.conversationStyle); - - _layout = [[ConversationViewLayout alloc] initWithConversationStyle:self.conversationStyle]; - self.conversationStyle.viewWidth = self.view.width; - - self.layout.delegate = self; - // We use the root view bounds as the initial frame for the collection - // view so that its contents can be laid out immediately. - // - // TODO: To avoid relayout, it'd be better to take into account safeAreaInsets, - // but they're not yet set when this method is called. - _collectionView = - [[ConversationCollectionView alloc] initWithFrame:self.view.bounds collectionViewLayout:self.layout]; - self.collectionView.layoutDelegate = self; - self.collectionView.delegate = self; - self.collectionView.dataSource = self; - self.collectionView.showsVerticalScrollIndicator = YES; - self.collectionView.showsHorizontalScrollIndicator = NO; - self.collectionView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; - if (@available(iOS 10, *)) { - // To minimize time to initial apearance, we initially disable prefetching, but then - // re-enable it once the view has appeared. - self.collectionView.prefetchingEnabled = NO; - } - [self.view addSubview:self.collectionView]; - [self.collectionView autoPinEdgeToSuperviewEdge:ALEdgeTop]; - [self.collectionView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; - [self.collectionView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading]; - [self.collectionView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; - - _progressIndicatorView = [UIProgressView new]; - [self.progressIndicatorView autoSetDimension:ALDimensionHeight toSize:LKValues.progressBarThickness]; - self.progressIndicatorView.progressViewStyle = UIProgressViewStyleBar; - self.progressIndicatorView.progressTintColor = LKColors.accent; - self.progressIndicatorView.trackTintColor = UIColor.clearColor; - self.progressIndicatorView.alpha = 0; - [self.view addSubview:self.progressIndicatorView]; - [self.progressIndicatorView autoPinEdgeToSuperviewEdge:ALEdgeTop]; - [self.progressIndicatorView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading]; - [self.progressIndicatorView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; - - [self.collectionView applyScrollViewInsetsFix]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _collectionView); - - _inputToolbar = [[ConversationInputToolbar alloc] initWithConversationStyle:self.conversationStyle]; - self.inputToolbar.inputToolbarDelegate = self; - self.inputToolbar.inputTextViewDelegate = self; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _inputToolbar); - - self.loadMoreHeader = [UILabel new]; - self.loadMoreHeader.text = NSLocalizedString(@"CONVERSATION_VIEW_LOADING_MORE_MESSAGES", @"Indicates that the app is loading more messages in this conversation."); - self.loadMoreHeader.textColor = [LKColors.text colorWithAlphaComponent:0.8]; - self.loadMoreHeader.textAlignment = NSTextAlignmentCenter; - self.loadMoreHeader.font = [UIFont boldSystemFontOfSize:LKValues.verySmallFontSize]; - [self.collectionView addSubview:self.loadMoreHeader]; - [self.loadMoreHeader autoPinWidthToWidthOfView:self.view]; - [self.loadMoreHeader autoPinEdgeToSuperviewEdge:ALEdgeTop]; - [self.loadMoreHeader autoSetDimension:ALDimensionHeight toSize:kLoadMoreHeaderHeight]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _loadMoreHeader); - - [self registerCellClasses]; - - [self updateShowLoadMoreHeader]; -} - -- (BOOL)becomeFirstResponder -{ - OWSLogDebug(@""); - return [super becomeFirstResponder]; -} - -- (BOOL)resignFirstResponder -{ - OWSLogDebug(@""); - return [super resignFirstResponder]; -} - -- (BOOL)canBecomeFirstResponder -{ - return YES; -} - -- (nullable UIView *)inputAccessoryView -{ - if (self.isShowingSearchUI) { - return self.searchController.resultsBar; - } else { - return self.inputToolbar; - } -} - -- (void)registerCellClasses -{ - [self.collectionView registerClass:[OWSSystemMessageCell class] - forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]]; - [self.collectionView registerClass:[OWSTypingIndicatorCell class] - forCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]]; - [self.collectionView registerClass:[OWSMessageCell class] - forCellWithReuseIdentifier:[OWSMessageCell cellReuseIdentifier]]; -} - -- (void)applicationWillEnterForeground:(NSNotification *)notification -{ - [self startReadTimer]; - [self updateCellsVisible]; -} - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - [self updateCellsVisible]; - [self.cellMediaCache removeAllObjects]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - [self cancelVoiceMemo]; - self.isUserScrolling = NO; - [self saveDraft]; - [self markVisibleMessagesAsRead]; - [self.cellMediaCache removeAllObjects]; - [self cancelReadTimer]; - [self dismissPresentedViewControllerIfNecessary]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - [self startReadTimer]; - [self resetContentAndLayout]; -} - -- (void)dismissPresentedViewControllerIfNecessary -{ - UIViewController *_Nullable presentedViewController = self.presentedViewController; - if (!presentedViewController) { - OWSLogDebug(@"presentedViewController was nil"); - return; - } - - if ([presentedViewController isKindOfClass:[UIAlertController class]]) { - OWSLogDebug(@"dismissing presentedViewController: %@", presentedViewController); - [self dismissViewControllerAnimated:NO completion:nil]; - return; - } - - if ([presentedViewController isKindOfClass:[UIImagePickerController class]]) { - OWSLogDebug(@"dismissing presentedViewController: %@", presentedViewController); - [self dismissViewControllerAnimated:NO completion:nil]; - return; - } -} - -- (void)viewWillAppear:(BOOL)animated -{ - OWSLogDebug(@"viewWillAppear"); - - [self ensureBannerState]; - [self updateSessionRestoreBanner]; - - [super viewWillAppear:animated]; - - // We need to recheck on every appearance, since the user may have left the group in the settings VC, - // or on another device. - [self hideInputIfNeeded]; - - self.isViewVisible = YES; - - [self updateDisappearingMessagesConfiguration]; - - [self updateBarButtonItems]; - - [self resetContentAndLayout]; - - // We want to set the initial scroll state the first time we enter the view. - if (!self.viewHasEverAppeared) { - [self scrollToDefaultPosition:NO]; - } else if (self.menuActionsViewController != nil) { - [self scrollToMenuActionInteraction:NO]; - } - - [self updateLastVisibleSortId]; - - if (!self.viewHasEverAppeared) { - NSTimeInterval appearenceDuration = CACurrentMediaTime() - self.viewControllerCreatedAt; - OWSLogVerbose(@"First viewWillAppear took: %.2fms", appearenceDuration * 1000); - } - [self updateInputBarLayout]; -} - -- (NSArray> *)viewItems -{ - return self.conversationViewModel.viewState.viewItems; -} - -- (ThreadDynamicInteractions *)dynamicInteractions -{ - return self.conversationViewModel.dynamicInteractions; -} - -- (NSIndexPath *_Nullable)indexPathOfUnreadMessagesIndicator -{ - NSNumber *_Nullable unreadIndicatorIndex = self.conversationViewModel.viewState.unreadIndicatorIndex; - if (unreadIndicatorIndex == nil) { - return nil; - } - return [NSIndexPath indexPathForRow:unreadIndicatorIndex.integerValue inSection:0]; -} - -- (NSIndexPath *_Nullable)indexPathOfMessageOnOpen -{ - OWSAssertDebug(self.conversationViewModel.focusMessageIdOnOpen); - OWSAssertDebug(self.dynamicInteractions.focusMessagePosition); - - if (!self.dynamicInteractions.focusMessagePosition) { - // This might happen if the focus message has disappeared - // before this view could appear. - OWSFailDebug(@"focus message has unknown position."); - return nil; - } - NSUInteger focusMessagePosition = self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue; - if (focusMessagePosition >= self.viewItems.count) { - // This might happen if the focus message is outside the maximum - // valid load window size for this view. - OWSFailDebug(@"focus message has invalid position."); - return nil; - } - NSInteger row = (NSInteger)((self.viewItems.count - 1) - focusMessagePosition); - return [NSIndexPath indexPathForRow:row inSection:0]; -} - -- (void)scrollToDefaultPosition:(BOOL)isAnimated -{ - if (self.isUserScrolling) { - return; - } - - NSIndexPath *_Nullable indexPath = nil; - if (self.conversationViewModel.focusMessageIdOnOpen) { - indexPath = [self indexPathOfMessageOnOpen]; - } - - if (!indexPath) { - indexPath = [self indexPathOfUnreadMessagesIndicator]; - } - - if (indexPath) { - if (indexPath.section == 0 && indexPath.row == 0) { - [self.collectionView setContentOffset:CGPointZero animated:isAnimated]; - } else { - [self.collectionView scrollToItemAtIndexPath:indexPath - atScrollPosition:UICollectionViewScrollPositionTop - animated:isAnimated]; - } - } else { - [self scrollToBottomAnimated:isAnimated]; - } -} - -- (void)scrollToUnreadIndicatorAnimated -{ - if (self.isUserScrolling) { - return; - } - - NSIndexPath *_Nullable indexPath = [self indexPathOfUnreadMessagesIndicator]; - if (indexPath) { - if (indexPath.section == 0 && indexPath.row == 0) { - [self.collectionView setContentOffset:CGPointZero animated:YES]; - } else { - [self.collectionView scrollToItemAtIndexPath:indexPath - atScrollPosition:UICollectionViewScrollPositionTop - animated:YES]; - } - } -} - -- (void)resetContentAndLayout -{ - self.scrollContinuity = kScrollContinuityBottom; - // Avoid layout corrupt issues and out-of-date message subtitles. - self.lastReloadDate = [NSDate new]; - [self.conversationViewModel viewDidResetContentAndLayout]; - [self.collectionView.collectionViewLayout invalidateLayout]; - [self.collectionView reloadSections:[NSIndexSet indexSetWithIndex:0]]; - - if (self.viewHasEverAppeared) { - // Try to update the lastKnownDistanceFromBottom; the content size may have changed. - [self updateLastKnownDistanceFromBottom]; - } -} - -- (void)setUserHasScrolled:(BOOL)userHasScrolled -{ - _userHasScrolled = userHasScrolled; - - [self ensureBannerState]; -} - -- (void)updateSessionRestoreBanner { -// BOOL isContactThread = [self.thread isKindOfClass:[TSContactThread class]]; -// BOOL shouldDetachBanner = !isContactThread; -// if (isContactThread) { -// TSContactThread *thread = (TSContactThread *)self.thread; -// if (thread.sessionRestoreDevices.count > 0) { -// if (self.restoreSessionBannerView == nil) { -// LKSessionRestorationView *bannerView = [[LKSessionRestorationView alloc] initWithThread:thread]; -// [self.view addSubview:bannerView]; -// [bannerView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:LKValues.mediumSpacing]; -// [bannerView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:LKValues.largeSpacing]; -// [bannerView autoPinEdgeToSuperviewEdge:ALEdgeRight withInset:LKValues.mediumSpacing]; -// [self.view layoutSubviews]; -// self.restoreSessionBannerView = bannerView; -// [bannerView setOnRestore:^{ -// [self restoreSession]; -// }]; -// [bannerView setOnDismiss:^{ -// [thread removeAllSessionRestoreDevicesWithTransaction:nil]; -// }]; -// } -// } else { -// shouldDetachBanner = true; -// } -// } -// if (shouldDetachBanner && self.restoreSessionBannerView != nil) { -// [self.restoreSessionBannerView removeFromSuperview]; -// self.restoreSessionBannerView = nil; -// } -} - -- (void)ensureBannerState -{ - // This method should be called rarely, so it's simplest to discard and - // rebuild the indicator view every time. - [self.bannerView removeFromSuperview]; - self.bannerView = nil; - - if (self.userHasScrolled) { - return; - } - - NSString *blockStateMessage = nil; - if ([self isBlockedConversation]) { - if (self.isGroupConversation) { - /* - blockStateMessage = NSLocalizedString( - @"MESSAGES_VIEW_GROUP_BLOCKED", @"Indicates that this group conversation has been blocked."); - */ - } else { - blockStateMessage = NSLocalizedString( - @"MESSAGES_VIEW_CONTACT_BLOCKED", @"Indicates that this 1:1 conversation has been blocked."); - } - } - - if (blockStateMessage) { - [self createBannerWithTitle:blockStateMessage - bannerColor:LKColors.destructive - tapSelector:@selector(blockBannerViewWasTapped:)]; - return; - } -} - -- (void)createBannerWithTitle:(NSString *)title bannerColor:(UIColor *)bannerColor tapSelector:(SEL)tapSelector -{ - OWSAssertDebug(title.length > 0); - OWSAssertDebug(bannerColor); - - UIView *bannerView = [UIView containerView]; - bannerView.backgroundColor = bannerColor; - bannerView.layer.cornerRadius = 2.5f; - - // Use a shadow to "pop" the indicator above the other views. - bannerView.layer.shadowColor = [UIColor blackColor].CGColor; - bannerView.layer.shadowOffset = CGSizeMake(2, 3); - bannerView.layer.shadowRadius = 2.f; - bannerView.layer.shadowOpacity = 0.35f; - - UILabel *label = [UILabel new]; - label.font = [UIFont ows_mediumFontWithSize:14.f]; - label.text = title; - label.textColor = [UIColor whiteColor]; - label.numberOfLines = 0; - label.lineBreakMode = NSLineBreakByWordWrapping; - label.textAlignment = NSTextAlignmentCenter; - - UIImage *closeIcon = [UIImage imageNamed:@"banner_close"]; - UIImageView *closeButton = [[UIImageView alloc] initWithImage:closeIcon]; - [bannerView addSubview:closeButton]; - const CGFloat kBannerCloseButtonPadding = 8.f; - [closeButton autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:kBannerCloseButtonPadding]; - [closeButton autoPinTrailingToSuperviewMarginWithInset:kBannerCloseButtonPadding]; - [closeButton autoSetDimension:ALDimensionWidth toSize:closeIcon.size.width]; - [closeButton autoSetDimension:ALDimensionHeight toSize:closeIcon.size.height]; - - [bannerView addSubview:label]; - [label autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:5]; - [label autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:5]; - const CGFloat kBannerHPadding = 15.f; - [label autoPinLeadingToSuperviewMarginWithInset:kBannerHPadding]; - const CGFloat kBannerHSpacing = 10.f; - [closeButton autoPinLeadingToTrailingEdgeOfView:label offset:kBannerHSpacing]; - - [bannerView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:tapSelector]]; - bannerView.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"banner_close"); - - [self.view addSubview:bannerView]; - [bannerView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:self.view withOffset:10.0f]; - [bannerView autoHCenterInSuperview]; - - CGFloat labelDesiredWidth = [label sizeThatFits:CGSizeZero].width; - CGFloat bannerDesiredWidth - = (labelDesiredWidth + kBannerHPadding + kBannerHSpacing + closeIcon.size.width + kBannerCloseButtonPadding); - const CGFloat kMinBannerHMargin = 20.f; - if (bannerDesiredWidth + kMinBannerHMargin * 2.f >= self.view.width) { - [bannerView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading withInset:kMinBannerHMargin]; - [bannerView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing withInset:kMinBannerHMargin]; - } - - [self.view layoutSubviews]; - - self.bannerView = bannerView; -} - -- (void)blockBannerViewWasTapped:(UIGestureRecognizer *)sender -{ - if (sender.state != UIGestureRecognizerStateRecognized) { - return; - } - - if ([self isBlockedConversation]) { - // If this a blocked conversation, offer to unblock. - [self showUnblockConversationUI:nil]; - } -} - -- (void)restoreSession { -// if (![self.thread isKindOfClass:TSContactThread.class]) { return; } -// TSContactThread *thread = (TSContactThread *)self.thread; -// __weak ConversationViewController *weakSelf = self; -// dispatch_async(dispatch_get_main_queue(), ^{ -// [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { -// [thread addSessionRestoreDevice:thread.contactIdentifier transaction:transaction]; -// [LKSessionManagementProtocol startSessionResetInThread:thread transaction:transaction]; -// }]; -// [weakSelf updateSessionRestoreBanner]; -// }); -} - -- (void)showUnblockConversationUI:(nullable BlockActionCompletionBlock)completionBlock -{ - self.userHasScrolled = NO; - - [UIView setAnimationsEnabled:NO]; - - [BlockListUIUtils showUnblockThreadActionSheet:self.thread - fromViewController:self - blockingManager:self.blockingManager - completionBlock:completionBlock]; - - [UIView setAnimationsEnabled:YES]; -} - -- (BOOL)isBlockedConversation -{ - return [self.blockingManager isThreadBlocked:self.thread]; -} - -- (int)blockedGroupMemberCount -{ - OWSAssertDebug(self.isGroupConversation); - OWSAssertDebug([self.thread isKindOfClass:[TSGroupThread class]]); - - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - int blockedMemberCount = 0; - NSArray *blockedPhoneNumbers = [self.blockingManager blockedPhoneNumbers]; - for (NSString *contactIdentifier in groupThread.groupModel.groupMemberIds) { - if ([blockedPhoneNumbers containsObject:contactIdentifier]) { - blockedMemberCount++; - } - } - return blockedMemberCount; -} - -- (void)startReadTimer -{ - [self.readTimer invalidate]; - self.readTimer = [NSTimer weakScheduledTimerWithTimeInterval:3.f - target:self - selector:@selector(readTimerDidFire) - userInfo:nil - repeats:YES]; -} - -- (void)readTimerDidFire -{ - [self markVisibleMessagesAsRead]; -} - -- (void)cancelReadTimer -{ - [self.readTimer invalidate]; - self.readTimer = nil; -} - -- (void)viewDidAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; - - // We don't present incoming message notifications for the presented - // conversation. But there's a narrow window *while* the conversationVC - // is being presented where a message notification for the not-quite-yet - // presented conversation can be shown. If that happens, dismiss it as soon - // as we enter the conversation. - [self.notificationPresenter cancelNotificationsWithThreadId:self.thread.uniqueId]; - - // recover status bar when returning from PhotoPicker, which is dark (uses light status bar) - [self setNeedsStatusBarAppearanceUpdate]; - - [self markVisibleMessagesAsRead]; - [self startReadTimer]; - [self autoLoadMoreIfNecessary]; - - if (!self.viewHasEverAppeared) { - // To minimize time to initial apearance, we initially disable prefetching, but then - // re-enable it once the view has appeared. - if (@available(iOS 10, *)) { - self.collectionView.prefetchingEnabled = YES; - } - } - - self.conversationViewModel.focusMessageIdOnOpen = nil; - - self.isViewCompletelyAppeared = YES; - self.viewHasEverAppeared = YES; - self.shouldAnimateKeyboardChanges = YES; - - // HACK: Because the inputToolbar is the inputAccessoryView, we make some special considertations WRT it's firstResponder status. - // - // When a view controller is presented, it is first responder. However if we resign first responder - // and the view re-appears, without being presented, the inputToolbar can become invisible. - // e.g. specifically works around the scenario: - // - Present this VC - // - Longpress on a message to show edit menu, which entails making the pressed view the first responder. - // - Begin presenting another view, e.g. swipe-left for details or swipe-right to go back, but quit part way, so that you remain on the conversation view - // - toolbar will be not be visible unless we reaquire first responder. - if (!self.isFirstResponder) { - - // We don't have to worry about the input toolbar being visible if the inputToolbar.textView is first responder - // In fact doing so would unnecessarily dismiss the keyboard which is probably not desirable and at least - // a distracting animation. - BOOL shouldBecomeFirstResponder = NO; - if (self.isShowingSearchUI) { - shouldBecomeFirstResponder = !self.searchController.uiSearchController.searchBar.isFirstResponder; - } else { - shouldBecomeFirstResponder = !self.inputToolbar.isInputTextViewFirstResponder; - } - - if (shouldBecomeFirstResponder) { - OWSLogDebug(@"reclaiming first responder to ensure toolbar is shown."); - [self becomeFirstResponder]; - } - } - - switch (self.actionOnOpen) { - case ConversationViewActionNone: - break; - case ConversationViewActionCompose: - [self popKeyBoard]; - break; - } - - // Clear the "on open" state after the view has been presented. - self.actionOnOpen = ConversationViewActionNone; - - [self updateInputBarLayout]; - [self ensureScrollDownButton]; - - if ([self.thread isKindOfClass:TSGroupThread.class] && !((TSGroupThread *)self.thread).isOpenGroup - && !((TSGroupThread *)self.thread).isClosedGroup) { - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Session" - message:@"Legacy closed groups are no longer supported. Please create a new group to continue." preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; - } -} - -// `viewWillDisappear` is called whenever the view *starts* to disappear, -// but, as is the case with the "pan left for message details view" gesture, -// this can be canceled. As such, we shouldn't tear down anything expensive -// until `viewDidDisappear`. -- (void)viewWillDisappear:(BOOL)animated -{ - OWSLogDebug(@""); - - [super viewWillDisappear:animated]; - - self.isViewCompletelyAppeared = NO; - - [self dismissMenuActions]; -} - -- (void)viewDidDisappear:(BOOL)animated -{ - OWSLogDebug(@""); - - [super viewDidDisappear:animated]; - self.userHasScrolled = NO; - self.isViewVisible = NO; - self.shouldAnimateKeyboardChanges = NO; - - [self.audioAttachmentPlayer stop]; - self.audioAttachmentPlayer = nil; - - [self cancelReadTimer]; - [self saveDraft]; - [self markVisibleMessagesAsRead]; - [self cancelVoiceMemo]; - [self.cellMediaCache removeAllObjects]; - - self.isUserScrolling = NO; -} - -- (void)viewDidLayoutSubviews -{ - [super viewDidLayoutSubviews]; - - // We resize the inputToolbar whenever it's text is modified, including when setting saved draft-text. - // However it's possible this draft-text is set before the inputToolbar (an inputAccessoryView) is mounted - // in the view hierarchy. Since it's not in the view hierarchy, it hasn't been laid out and has no width, - // which is used to determine height. - // So here we unsure the proper height once we know everything's been layed out. - [self.inputToolbar ensureTextViewHeight]; -} - -#pragma mark - Initiliazers - -- (void)createHeaderViews -{ - LKConversationTitleView *headerView = [[LKConversationTitleView alloc] initWithThread:self.thread]; - self.headerView = headerView; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, headerView); - - self.navigationItem.titleView = headerView; - - if (@available(iOS 11, *)) { - // Do nothing, we use autolayout/intrinsic content size to grow - } else { - // Request "full width" title; the navigation bar will truncate this - // to fit between the left and right buttons. - CGSize screenSize = [UIScreen mainScreen].bounds.size; - CGRect headerFrame = CGRectMake(0, 0, screenSize.width, 44); - headerView.frame = headerFrame; - } -} - -- (CGFloat)unreadCountViewDiameter -{ - return 16; -} - -- (void)createBackButton -{ - UIBarButtonItem *backItem = [self createOWSBackButton]; - self.customBackButton = backItem; - if (backItem.customView) { - // This method gets called multiple times, so it's important we re-layout the unread badge - // with respect to the new backItem. - [backItem.customView addSubview:_backButtonUnreadCountView]; - // TODO: The back button assets are assymetrical. There are strong reasons - // to use spacing in the assets to manipulate the size and positioning of - // bar button items, but it means we'll probably need separate RTL and LTR - // flavors of these assets. - [_backButtonUnreadCountView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:-6]; - [_backButtonUnreadCountView autoPinLeadingToSuperviewMarginWithInset:1]; - [_backButtonUnreadCountView autoSetDimension:ALDimensionHeight toSize:self.unreadCountViewDiameter]; - // We set a min width, but we will also pin to our subview label, so we can grow to accommodate multiple digits. - [_backButtonUnreadCountView autoSetDimension:ALDimensionWidth - toSize:self.unreadCountViewDiameter - relation:NSLayoutRelationGreaterThanOrEqual]; - - [_backButtonUnreadCountView addSubview:_backButtonUnreadCountLabel]; - [_backButtonUnreadCountLabel autoPinWidthToSuperviewWithMargin:4]; - [_backButtonUnreadCountLabel autoPinHeightToSuperview]; - } - - self.navigationItem.leftBarButtonItem = backItem; -} - -- (void)windowManagerCallDidChange:(NSNotification *)notification -{ - [self updateBarButtonItems]; -} - -- (void)updateBarButtonItems -{ - return; // Loki: Re-enable later? - - self.navigationItem.hidesBackButton = NO; - if (self.customBackButton) { - self.navigationItem.leftBarButtonItem = self.customBackButton; - } - - if (self.userLeftGroup) { - self.navigationItem.rightBarButtonItems = @[]; - return; - } - - if (self.isShowingSearchUI) { - self.navigationItem.rightBarButtonItems = @[]; - self.navigationItem.leftBarButtonItem = nil; - self.navigationItem.hidesBackButton = YES; - return; - } - - const CGFloat kBarButtonSize = 44; - NSMutableArray *barButtons = [NSMutableArray new]; - - if (self.disappearingMessagesConfiguration.isEnabled) { - DisappearingTimerConfigurationView *timerView = [[DisappearingTimerConfigurationView alloc] - initWithDurationSeconds:self.disappearingMessagesConfiguration.durationSeconds]; - timerView.delegate = self; - timerView.tintColor = Theme.navbarIconColor; - - // As of iOS11, we can size barButton item custom views with autoLayout. - // Before that, though we can still use autoLayout *within* the customView, - // setting the view's size with constraints causes the customView to be temporarily - // laid out with a misplaced origin. - if (@available(iOS 11.0, *)) { - [timerView autoSetDimensionsToSize:CGSizeMake(36, 44)]; - } else { - timerView.frame = CGRectMake(0, 0, 36, 44); - } - - [barButtons - addObject:[[UIBarButtonItem alloc] initWithCustomView:timerView - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"timer")]]; - } - - self.navigationItem.rightBarButtonItems = [barButtons copy]; -} - -#pragma mark - Dynamic Text - -/** - Called whenever the user manually changes the dynamic type options inside Settings. - - @param notification NSNotification with the dynamic type change information. - */ -- (void)didChangePreferredContentSize:(NSNotification *)notification -{ - OWSLogInfo(@"didChangePreferredContentSize"); - - [self resetForSizeOrOrientationChange]; - - [self.inputToolbar updateFontSizes]; -} - -#pragma mark - Actions - -- (void)showConversationSettings -{ - [self showConversationSettingsAndShowVerification:NO]; -} - -- (void)showConversationSettingsAndShowVerification:(BOOL)showVerification -{ - OWSConversationSettingsViewController *settingsVC = [OWSConversationSettingsViewController new]; - settingsVC.conversationSettingsViewDelegate = self; - [settingsVC configureWithThread:self.thread uiDatabaseConnection:self.uiDatabaseConnection]; - settingsVC.showVerificationOnAppear = showVerification; - [self.navigationController pushViewController:settingsVC animated:YES]; -} - -#pragma mark - DisappearingTimerConfigurationViewDelegate - -- (void)disappearingTimerConfigurationViewWasTapped:(DisappearingTimerConfigurationView *)disappearingTimerView -{ - OWSLogDebug(@"Tapped timer in navbar"); - [self showConversationSettings]; -} - -#pragma mark - Load More - -- (void)autoLoadMoreIfNecessary -{ - BOOL isMainAppAndActive = CurrentAppContext().isMainAppAndActive; - if (self.isUserScrolling || !self.isViewVisible || !isMainAppAndActive) { - return; - } - if (!self.showLoadMoreHeader) { - return; - } - CGSize screenSize = UIScreen.mainScreen.bounds.size; - CGFloat loadMoreThreshold = MAX(screenSize.width, screenSize.height); - if (self.collectionView.contentOffset.y < loadMoreThreshold) { - [self.conversationViewModel loadAnotherPageOfMessages]; - } -} - -- (void)updateShowLoadMoreHeader -{ - OWSAssertDebug(self.conversationViewModel); - - self.showLoadMoreHeader = self.conversationViewModel.canLoadMoreItems; -} - -- (void)setShowLoadMoreHeader:(BOOL)showLoadMoreHeader -{ - BOOL valueChanged = _showLoadMoreHeader != showLoadMoreHeader; - - _showLoadMoreHeader = showLoadMoreHeader; - - self.loadMoreHeader.hidden = !showLoadMoreHeader; - self.loadMoreHeader.userInteractionEnabled = showLoadMoreHeader; - - if (valueChanged) { - [self resetContentAndLayout]; - } -} - -- (void)updateDisappearingMessagesConfiguration -{ - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - self.disappearingMessagesConfiguration = - [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId transaction:transaction]; - }]; -} - -- (void)setDisappearingMessagesConfiguration: - (nullable OWSDisappearingMessagesConfiguration *)disappearingMessagesConfiguration -{ - if (_disappearingMessagesConfiguration.isEnabled == disappearingMessagesConfiguration.isEnabled - && _disappearingMessagesConfiguration.durationSeconds == disappearingMessagesConfiguration.durationSeconds) { - return; - } - - _disappearingMessagesConfiguration = disappearingMessagesConfiguration; - [self updateBarButtonItems]; -} - -#pragma mark Bubble User Actions - -- (void)handleFailedDownloadTapForMessage:(TSMessage *)message -{ - // Do nothing -} - -- (void)handleUnsentMessageTap:(TSOutgoingMessage *)tsMessage -{ - UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:tsMessage.mostRecentFailureText - message:nil - preferredStyle:UIAlertControllerStyleActionSheet]; - - [actionSheet addAction:[OWSAlerts cancelAction]]; - - UIAlertAction *deleteMessageAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"TXT_DELETE_TITLE", @"") - style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *action) { - [self remove:tsMessage]; - }]; - [actionSheet addAction:deleteMessageAction]; - - UIAlertAction *resendMessageAction = [UIAlertAction - actionWithTitle:NSLocalizedString(@"SEND_AGAIN_BUTTON", @"") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_again") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - SNVisibleMessage *message = [SNVisibleMessage from:tsMessage]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - NSMutableArray *attachments = @[].mutableCopy; - for (NSString *attachmentID in tsMessage.attachmentIds) { - TSAttachmentStream *stream = [TSAttachmentStream fetchObjectWithUniqueID:attachmentID transaction:transaction]; - if (![stream isKindOfClass:TSAttachmentStream.class]) { continue; } - [attachments addObject:stream]; - } - [SNMessageSender prep:attachments forMessage:message usingTransaction: transaction]; - [SNMessageSender send:message inThread:self.thread usingTransaction:transaction]; - }]; - }]; - - [actionSheet addAction:resendMessageAction]; - - [self dismissKeyBoard]; - [self presentAlert:actionSheet]; -} - -- (void)remove:(TSOutgoingMessage *)message -{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [message removeWithTransaction:transaction]; - [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:message.timestamp using:transaction]; - }]; -} - -- (void)tappedCorruptedMessage:(TSErrorMessage *)message -{ - NSString *alertMessage = [NSString - stringWithFormat:NSLocalizedString(@"CORRUPTED_SESSION_DESCRIPTION", @"ActionSheet title"), self.thread.name]; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil - message:alertMessage - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[OWSAlerts cancelAction]]; - - [self dismissKeyBoard]; - [self presentAlert:alert]; -} - -#pragma mark - MessageActionsDelegate - -- (void)messageActionsShowDetailsForItem:(id)conversationViewItem -{ - [self showDetailViewForViewItem:conversationViewItem]; -} - -- (void)banUser:(id)conversationViewItem -{ - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Ban This User?" message:nil preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString* publicKey; - if ([conversationViewItem.interaction isKindOfClass:TSIncomingMessage.class]) { - publicKey = ((TSIncomingMessage *)conversationViewItem.interaction).authorId; - } - SNOpenGroup *openGroup = [LKStorage.shared getOpenGroupForThreadID:self.thread.uniqueId]; - if (openGroup == nil) return; - [[SNOpenGroupAPI banPublicKey:publicKey fromServer:openGroup.server] retainUntilComplete]; - }]]; - [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleDefault handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)messageActionsReplyToItem:(id)conversationViewItem -{ - [self populateReplyForViewItem:conversationViewItem]; -} - -- (void)copyPublicKeyFor:(id)conversationViewItem -{ - UIPasteboard.generalPasteboard.string = ((TSIncomingMessage *)conversationViewItem.interaction).authorId; -} - -#pragma mark - MessageDetailViewDelegate - -- (void)detailViewMessageWasDeleted:(MessageDetailViewController *)messageDetailViewController -{ - OWSLogInfo(@""); - [self.navigationController popToViewController:self animated:YES]; -} - -#pragma mark - LongTextViewDelegate - -- (void)longTextViewMessageWasDeleted:(LongTextViewController *)longTextViewController -{ - OWSLogInfo(@""); - [self.navigationController popToViewController:self animated:YES]; -} - -#pragma mark - MenuActionsViewControllerDelegate - -- (void)menuActionsWillPresent:(MenuActionsViewController *)menuActionsViewController -{ - OWSLogVerbose(@""); - - // While the menu actions are presented, temporarily use extra content - // inset padding so that interactions near the top or bottom of the - // collection view can be scrolled anywhere within the viewport. - // - // e.g. In a new conversation, there might be only a single message - // which we might want to scroll to the bottom of the screen to - // pin above the menu actions popup. - CGSize mainScreenSize = UIScreen.mainScreen.bounds.size; - self.extraContentInsetPadding = MAX(mainScreenSize.width, mainScreenSize.height); - - UIEdgeInsets contentInset = self.collectionView.contentInset; - contentInset.top += self.extraContentInsetPadding; - contentInset.bottom += self.extraContentInsetPadding; - self.collectionView.contentInset = contentInset; - - self.menuActionsViewController = menuActionsViewController; -} - -- (void)menuActionsIsPresenting:(MenuActionsViewController *)menuActionsViewController -{ - OWSLogVerbose(@""); - - // Changes made in this "is presenting" callback are animated by the caller. - [self scrollToMenuActionInteraction:NO]; -} - -- (void)menuActionsDidPresent:(MenuActionsViewController *)menuActionsViewController -{ - OWSLogVerbose(@""); - - [self scrollToMenuActionInteraction:NO]; -} - -- (void)menuActionsIsDismissing:(MenuActionsViewController *)menuActionsViewController -{ - OWSLogVerbose(@""); - - // Changes made in this "is dismissing" callback are animated by the caller. - [self clearMenuActionsState]; -} - -- (void)menuActionsDidDismiss:(MenuActionsViewController *)menuActionsViewController -{ - OWSLogVerbose(@""); - - [self dismissMenuActions]; -} - -- (void)dismissMenuActions -{ - OWSLogVerbose(@""); - - [self clearMenuActionsState]; - [[OWSWindowManager sharedManager] hideMenuActionsWindow]; -} - -- (void)clearMenuActionsState -{ - OWSLogVerbose(@""); - - if (self.menuActionsViewController == nil) { - return; - } - - UIEdgeInsets contentInset = self.collectionView.contentInset; - contentInset.top -= self.extraContentInsetPadding; - contentInset.bottom -= self.extraContentInsetPadding; - self.collectionView.contentInset = contentInset; - - self.menuActionsViewController = nil; - self.extraContentInsetPadding = 0; -} - -- (void)scrollToMenuActionInteractionIfNecessary -{ - if (self.menuActionsViewController != nil) { - [self scrollToMenuActionInteraction:NO]; - } -} - -- (void)scrollToMenuActionInteraction:(BOOL)animated -{ - OWSAssertDebug(self.menuActionsViewController); - - NSValue *_Nullable contentOffset = [self contentOffsetForMenuActionInteraction]; - if (contentOffset == nil) { -// OWSFailDebug(@"Missing contentOffset."); - return; - } - [self.collectionView setContentOffset:contentOffset.CGPointValue animated:animated]; -} - -- (nullable NSValue *)contentOffsetForMenuActionInteraction -{ - OWSAssertDebug(self.menuActionsViewController); - - NSString *_Nullable menuActionInteractionId = self.menuActionsViewController.focusedInteraction.uniqueId; - if (menuActionInteractionId == nil) { - OWSFailDebug(@"Missing menu action interaction."); - return nil; - } - CGPoint modalTopWindow = [self.menuActionsViewController.focusUI convertPoint:CGPointZero toView:nil]; - CGPoint modalTopLocal = [self.view convertPoint:modalTopWindow fromView:nil]; - CGPoint offset = modalTopLocal; - CGFloat focusTop = offset.y - self.menuActionsViewController.vSpacing; - - NSNumber *_Nullable interactionIndex - = self.conversationViewModel.viewState.interactionIndexMap[menuActionInteractionId]; - if (interactionIndex == nil) { - // This is expected if the menu action interaction is being deleted. - return nil; - } - NSIndexPath *indexPath = [NSIndexPath indexPathForRow:interactionIndex.integerValue inSection:0]; - UICollectionViewLayoutAttributes *_Nullable layoutAttributes = - [self.layout layoutAttributesForItemAtIndexPath:indexPath]; - if (layoutAttributes == nil) { -// OWSFailDebug(@"Missing layoutAttributes."); - return nil; - } - CGRect cellFrame = layoutAttributes.frame; - return [NSValue valueWithCGPoint:CGPointMake(0, CGRectGetMaxY(cellFrame) - focusTop)]; -} - -- (void)dismissMenuActionsIfNecessary -{ - if (self.shouldDismissMenuActions) { - [self dismissMenuActions]; - } -} - -- (BOOL)shouldDismissMenuActions -{ - if (!OWSWindowManager.sharedManager.isPresentingMenuActions) { - return NO; - } - NSString *_Nullable menuActionInteractionId = self.menuActionsViewController.focusedInteraction.uniqueId; - if (menuActionInteractionId == nil) { - return NO; - } - // Check whether there is still a view item for this interaction. - return (self.conversationViewModel.viewState.interactionIndexMap[menuActionInteractionId] == nil); -} - -#pragma mark - ConversationViewCellDelegate - -- (void)conversationCell:(ConversationViewCell *)cell - shouldAllowReply:(BOOL)shouldAllowReply - didLongpressMediaViewItem:(id)viewItem -{ - NSArray *messageActions = - [ConversationViewItemActions mediaActionsWithConversationViewItem:viewItem - shouldAllowReply:shouldAllowReply - delegate:self]; - [self presentMessageActions:messageActions withFocusedCell:cell]; -} - -- (void)conversationCell:(ConversationViewCell *)cell - shouldAllowReply:(BOOL)shouldAllowReply - didLongpressTextViewItem:(id)viewItem -{ - NSArray *messageActions = - [ConversationViewItemActions textActionsWithConversationViewItem:viewItem - shouldAllowReply:shouldAllowReply - delegate:self]; - [self presentMessageActions:messageActions withFocusedCell:cell]; -} - -- (void)conversationCell:(ConversationViewCell *)cell - shouldAllowReply:(BOOL)shouldAllowReply - didLongpressQuoteViewItem:(id)viewItem -{ - NSArray *messageActions = - [ConversationViewItemActions quotedMessageActionsWithConversationViewItem:viewItem - shouldAllowReply:shouldAllowReply - delegate:self]; - [self presentMessageActions:messageActions withFocusedCell:cell]; -} - -- (void)conversationCell:(ConversationViewCell *)cell - didLongpressSystemMessageViewItem:(id)viewItem -{ - NSArray *messageActions = - [ConversationViewItemActions infoMessageActionsWithConversationViewItem:viewItem delegate:self]; - [self presentMessageActions:messageActions withFocusedCell:cell]; -} - -- (void)presentMessageActions:(NSArray *)messageActions withFocusedCell:(ConversationViewCell *)cell -{ - MenuActionsViewController *menuActionsViewController = - [[MenuActionsViewController alloc] initWithFocusedInteraction:cell.viewItem.interaction - focusedView:cell - actions:messageActions]; - - menuActionsViewController.delegate = self; - - [[OWSWindowManager sharedManager] showMenuActionsWindow:menuActionsViewController]; -} - -#pragma mark - OWSMessageBubbleViewDelegate - -- (void)didTapImageViewItem:(id)viewItem - attachmentStream:(TSAttachmentStream *)attachmentStream - imageView:(UIView *)imageView -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - OWSAssertDebug(attachmentStream); - OWSAssertDebug(imageView); - - [self dismissKeyBoard]; - - // In case we were presenting edit menu, we need to become first responder before presenting another VC - // else UIKit won't restore first responder status to us when the presented VC is dismissed. - if (!self.isFirstResponder) { - [self becomeFirstResponder]; - } - - MediaGallery *mediaGallery = - [[MediaGallery alloc] initWithThread:self.thread - options:MediaGalleryOptionSliderEnabled | MediaGalleryOptionShowAllMediaButton]; - - [mediaGallery presentDetailViewFromViewController:self mediaAttachment:attachmentStream replacingView:imageView]; -} - -- (void)didTapVideoViewItem:(id)viewItem - attachmentStream:(TSAttachmentStream *)attachmentStream - imageView:(UIImageView *)imageView -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - OWSAssertDebug(attachmentStream); - - [self dismissKeyBoard]; - // In case we were presenting edit menu, we need to become first responder before presenting another VC - // else UIKit won't restore first responder status to us when the presented VC is dismissed. - if (!self.isFirstResponder) { - [self becomeFirstResponder]; - } - - MediaGallery *mediaGallery = - [[MediaGallery alloc] initWithThread:self.thread - options:MediaGalleryOptionSliderEnabled | MediaGalleryOptionShowAllMediaButton]; - - [mediaGallery presentDetailViewFromViewController:self mediaAttachment:attachmentStream replacingView:imageView]; -} - -- (void)didTapAudioViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - OWSAssertDebug(attachmentStream); - - NSFileManager *fileManager = [NSFileManager defaultManager]; - if (![fileManager fileExistsAtPath:attachmentStream.originalFilePath]) { - OWSFailDebug(@"Missing audio file: %@", attachmentStream.originalMediaURL); - } - - [self dismissKeyBoard]; - - if (self.audioAttachmentPlayer) { - // Is this player associated with this media adapter? - if (self.audioAttachmentPlayer.owner == viewItem) { - // Tap to pause & unpause. - [self.audioAttachmentPlayer togglePlayState]; - return; - } - [self.audioAttachmentPlayer stop]; - self.audioAttachmentPlayer = nil; - } - - self.audioAttachmentPlayer = - [[OWSAudioPlayer alloc] initWithMediaUrl:attachmentStream.originalMediaURL audioBehavior:OWSAudioBehavior_AudioMessagePlayback delegate:viewItem]; - - // Associate the player with this media adapter. - self.audioAttachmentPlayer.owner = viewItem; - [self.audioAttachmentPlayer play]; - [self.audioAttachmentPlayer setCurrentTime:viewItem.audioProgressSeconds]; -} - -- (void)didPanAudioViewItemToCurrentTime:(NSTimeInterval)currentTime -{ - [self.audioAttachmentPlayer setCurrentTime:currentTime]; -} - -- (void)didTapTruncatedTextMessage:(id)conversationItem -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(conversationItem); - OWSAssertDebug([conversationItem.interaction isKindOfClass:[TSMessage class]]); - - LongTextViewController *viewController = [[LongTextViewController alloc] initWithViewItem:conversationItem]; - viewController.delegate = self; - [self.navigationController pushViewController:viewController animated:YES]; -} - -- (void)didTapFailedIncomingAttachment:(id)viewItem -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - - // Restart failed downloads - TSMessage *message = (TSMessage *)viewItem.interaction; - [self handleFailedDownloadTapForMessage:message]; -} - -- (void)didTapFailedOutgoingMessage:(TSOutgoingMessage *)message -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(message); - - [self handleUnsentMessageTap:message]; -} - -- (void)didTapConversationItem:(id)viewItem - quotedReply:(OWSQuotedReplyModel *)quotedReply - failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer -{ - // Do nothing -} - -- (void)didTapConversationItem:(id)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - OWSAssertDebug(quotedReply); - OWSAssertDebug(quotedReply.timestamp > 0); - OWSAssertDebug(quotedReply.authorId.length > 0); - - NSIndexPath *_Nullable indexPath = [self.conversationViewModel ensureLoadWindowContainsQuotedReply:quotedReply]; - if (!indexPath) { - [self presentRemotelySourcedQuotedReplyToast]; - return; - } - - [self.collectionView scrollToItemAtIndexPath:indexPath - atScrollPosition:UICollectionViewScrollPositionTop - animated:YES]; - - // TODO: Highlight the quoted message? -} - -- (void)didTapConversationItem:(id)viewItem linkPreview:(OWSLinkPreview *)linkPreview -{ - OWSAssertIsOnMainThread(); - - NSURL *_Nullable url = [NSURL URLWithString:linkPreview.urlString]; - if (!url) { - OWSFailDebug(@"Invalid link preview URL."); - return; - } - - [UIApplication.sharedApplication openURL:url]; -} - -- (void)showDetailViewForViewItem:(id)conversationItem -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(conversationItem); - OWSAssertDebug([conversationItem.interaction isKindOfClass:[TSMessage class]]); - - TSMessage *message = (TSMessage *)conversationItem.interaction; - MessageDetailViewController *detailVC = - [[MessageDetailViewController alloc] initWithViewItem:conversationItem - message:message - thread:self.thread - mode:MessageMetadataViewModeFocusOnMetadata]; - detailVC.delegate = self; - [self.navigationController pushViewController:detailVC animated:YES]; -} - -- (void)populateReplyForViewItem:(id)conversationItem -{ - OWSLogDebug(@"user did tap reply"); - - __block OWSQuotedReplyModel *quotedReply; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - quotedReply = [OWSQuotedReplyModel quotedReplyForSendingWithConversationViewItem:conversationItem - threadId:conversationItem.interaction.uniqueThreadId - transaction:transaction]; - }]; - - if (![quotedReply isKindOfClass:[OWSQuotedReplyModel class]]) { - OWSFailDebug(@"unexpected quotedMessage: %@", quotedReply.class); - return; - } - - self.inputToolbar.quotedReply = quotedReply; - [self.inputToolbar beginEditingTextMessage]; -} - -#pragma mark - ContactEditingDelegate - -- (void)didFinishEditingContact -{ - OWSLogDebug(@""); - - [self dismissViewControllerAnimated:NO completion:nil]; -} - -#pragma mark - CNContactViewControllerDelegate - -- (void)contactViewController:(CNContactViewController *)viewController - didCompleteWithContact:(nullable CNContact *)contact -{ - if (contact) { - // Saving normally returns you to the "Show Contact" view - // which we're not interested in, so we skip it here. There is - // an unfortunate blip of the "Show Contact" view on slower devices. - OWSLogDebug(@"completed editing contact."); - [self dismissViewControllerAnimated:NO completion:nil]; - } else { - OWSLogDebug(@"canceled editing contact."); - [self dismissViewControllerAnimated:YES completion:nil]; - } -} - -#pragma mark - ContactsViewHelperDelegate - -- (void)contactsViewHelperDidUpdateContacts -{ - [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; -} - -- (void)createConversationScrollButtons -{ - self.scrollDownButton = [[ConversationScrollButton alloc] initWithIconText:@"\uf107"]; - [self.scrollDownButton addTarget:self - action:@selector(scrollDownButtonTapped) - forControlEvents:UIControlEventTouchUpInside]; - [self.view addSubview:self.scrollDownButton]; - [self.scrollDownButton autoSetDimension:ALDimensionWidth toSize:ConversationScrollButton.buttonSize]; - [self.scrollDownButton autoSetDimension:ALDimensionHeight toSize:ConversationScrollButton.buttonSize]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _scrollDownButton); - - // The "scroll down" button layout tracks the content inset of the collection view, - // so pin to the edge of the collection view. - self.scrollDownButtonButtomConstraint = - [self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.view]; - [self.scrollDownButton autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; - - [self updateScrollDownButtonLayout]; -} - -- (void)updateScrollDownButtonLayout -{ - CGFloat inset = -(self.collectionView.contentInset.bottom + self.bottomLayoutGuide.length); - self.scrollDownButtonButtomConstraint.constant = inset; - [self.scrollDownButton.superview setNeedsLayout]; -} - -- (void)setHasUnreadMessages:(BOOL)hasUnreadMessages -{ - if (_hasUnreadMessages == hasUnreadMessages) { - return; - } - - _hasUnreadMessages = hasUnreadMessages; - - self.scrollDownButton.hasUnreadMessages = hasUnreadMessages; - [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; -} - -- (void)scrollDownButtonTapped -{ - NSIndexPath *indexPathOfUnreadMessagesIndicator = [self indexPathOfUnreadMessagesIndicator]; - if (indexPathOfUnreadMessagesIndicator != nil) { - NSInteger unreadRow = indexPathOfUnreadMessagesIndicator.row; - - BOOL isScrolledAboveUnreadIndicator = YES; - NSArray *visibleIndices = self.collectionView.indexPathsForVisibleItems; - for (NSIndexPath *indexPath in visibleIndices) { - if (indexPath.row > unreadRow) { - isScrolledAboveUnreadIndicator = NO; - break; - } - } - - if (isScrolledAboveUnreadIndicator) { - // Only scroll as far as the unread indicator if we're scrolled above the unread indicator. - [[self collectionView] scrollToItemAtIndexPath:indexPathOfUnreadMessagesIndicator - atScrollPosition:UICollectionViewScrollPositionTop - animated:YES]; - return; - } - } - - [self scrollToBottomAnimated:YES]; -} - -- (void)ensureScrollDownButton -{ - OWSAssertIsOnMainThread(); - - BOOL shouldShowScrollDownButton = NO; - CGFloat scrollSpaceToBottom = (self.safeContentHeight + self.collectionView.contentInset.bottom - - (self.collectionView.contentOffset.y + self.collectionView.frame.size.height)); - CGFloat pageHeight = (self.collectionView.frame.size.height - - (self.collectionView.contentInset.top + self.collectionView.contentInset.bottom)); - // Show "scroll down" button if user is scrolled up at least - // one page. - BOOL isScrolledUp = scrollSpaceToBottom > pageHeight * 1.f; - - if (self.viewItems.count > 0) { - id lastViewItem = [self.viewItems lastObject]; - OWSAssertDebug(lastViewItem); - - if (lastViewItem.interaction.sortId > self.lastVisibleSortId) { - shouldShowScrollDownButton = YES; - } else if (isScrolledUp) { - shouldShowScrollDownButton = YES; - } - } - - self.scrollDownButton.hidden = !shouldShowScrollDownButton; -} - -#pragma mark - Attachment Picking: Documents - -- (void)showAttachmentDocumentPickerMenu -{ - NSString *allItems = (__bridge NSString *)kUTTypeItem; - NSArray *documentTypes = @[ allItems ]; - // UIDocumentPickerModeImport copies to a temp file within our container. - // It uses more memory than "open" but lets us avoid working with security scoped URLs. - UIDocumentPickerMode pickerMode = UIDocumentPickerModeImport; - // TODO: UIDocumentMenuViewController is deprecated; we should use UIDocumentPickerViewController - // instead. - UIDocumentMenuViewController *menuController = - [[UIDocumentMenuViewController alloc] initWithDocumentTypes:documentTypes inMode:pickerMode]; - menuController.delegate = self; - - UIImage *takeMediaImage = [UIImage imageNamed:@"actionsheet_camera_black"]; - OWSAssertDebug(takeMediaImage); - [menuController addOptionWithTitle:NSLocalizedString( - @"MEDIA_FROM_LIBRARY_BUTTON", @"media picker option to choose from library") - image:takeMediaImage - order:UIDocumentMenuOrderFirst - handler:^{ - [self chooseFromLibraryAsDocument]; - }]; - - [self dismissKeyBoard]; - [self presentViewController:menuController animated:YES completion:nil]; -} - -#pragma mark - Attachment Picking: GIFs - -- (void)showGIFMetadataWarning -{ - NSString *title = NSLocalizedString(@"Search GIFs?", @""); - NSString *message = NSLocalizedString(@"You will not have full metadata protection when sending GIFs.", @""); - UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - [self showGifPicker]; - }]]; - [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleDefault handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showGifPicker -{ - GifPickerViewController *view = - [[GifPickerViewController alloc] initWithThread:self.thread]; - view.delegate = self; - OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:view]; - - [self dismissKeyBoard]; - [self presentViewController:navigationController animated:YES completion:nil]; -} - -#pragma mark GifPickerViewControllerDelegate - -- (void)gifPickerDidSelectWithAttachment:(SignalAttachment *)attachment -{ - OWSAssertDebug(attachment); - - [self showApprovalDialogForAttachment:attachment]; - - [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; -} - -- (void)messageWasSent:(TSOutgoingMessage *)message -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(message); - - self.lastMessageSentDate = [NSDate new]; - [self.conversationViewModel clearUnreadMessagesIndicator]; - self.inputToolbar.quotedReply = nil; - - if (!Environment.shared.preferences.hasSentAMessage) { - [Environment.shared.preferences setHasSentAMessage:YES]; - } - if ([Environment.shared.preferences soundInForeground]) { - SystemSoundID soundId = [OWSSounds systemSoundIDForSound:OWSSound_MessageSent quiet:YES]; - AudioServicesPlaySystemSound(soundId); - } - [self.typingIndicators didSendOutgoingMessageInThread:self.thread]; -} - -#pragma mark UIDocumentMenuDelegate - -- (void)documentMenu:(UIDocumentMenuViewController *)documentMenu - didPickDocumentPicker:(UIDocumentPickerViewController *)documentPicker -{ - documentPicker.delegate = self; - - [self dismissKeyBoard]; - [self presentViewController:documentPicker animated:YES completion:nil]; -} - -#pragma mark UIDocumentPickerDelegate - -- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url -{ - OWSLogDebug(@"Picked document at url: %@", url); - - NSString *type; - NSError *typeError; - [url getResourceValue:&type forKey:NSURLTypeIdentifierKey error:&typeError]; - if (typeError) { - OWSFailDebug(@"Determining type of picked document at url: %@ failed with error: %@", url, typeError); - } - if (!type) { - OWSFailDebug(@"falling back to default filetype for picked document at url: %@", url); - type = (__bridge NSString *)kUTTypeData; - } - - NSNumber *isDirectory; - NSError *isDirectoryError; - [url getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:&isDirectoryError]; - if (isDirectoryError) { - OWSFailDebug(@"Determining if picked document was a directory failed with error: %@", isDirectoryError); - } else if ([isDirectory boolValue]) { - OWSLogInfo(@"User picked directory."); - - dispatch_async(dispatch_get_main_queue(), ^{ - [OWSAlerts - showAlertWithTitle: - NSLocalizedString(@"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE", - @"Alert title when picking a document fails because user picked a directory/bundle") - message: - NSLocalizedString(@"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY", - @"Alert body when picking a document fails because user picked a directory/bundle")]; - }); - return; - } - - NSString *filename = url.lastPathComponent; - if (!filename) { - OWSFailDebug(@"Unable to determine filename"); - filename = NSLocalizedString( - @"ATTACHMENT_DEFAULT_FILENAME", @"Generic filename for an attachment with no known name"); - } - - OWSAssertDebug(type); - OWSAssertDebug(filename); - DataSource *_Nullable dataSource = [DataSourcePath dataSourceWithURL:url shouldDeleteOnDeallocation:NO]; - if (!dataSource) { - OWSFailDebug(@"attachment data was unexpectedly empty for picked document"); - - dispatch_async(dispatch_get_main_queue(), ^{ - [OWSAlerts showAlertWithTitle:NSLocalizedString(@"ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE", - @"Alert title when picking a document fails for an unknown reason")]; - }); - return; - } - - [dataSource setSourceFilename:filename]; - - // Although we want to be able to send higher quality attachments through the document picker - // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) - if ([SignalAttachment isInvalidVideoWithDataSource:dataSource dataUTI:type]) { - [self showApprovalDialogAfterProcessingVideoURL:url filename:filename]; - return; - } - - // "Document picker" attachments _SHOULD NOT_ be resized, if possible. - SignalAttachment *attachment = - [SignalAttachment attachmentWithDataSource:dataSource dataUTI:type imageQuality:TSImageQualityOriginal]; - [self showApprovalDialogForAttachment:attachment]; -} - -#pragma mark - UIImagePickerController - -/* - * Presenting UIImagePickerController - */ -- (void)takePictureOrVideo -{ - [self ows_askForCameraPermissions:^(BOOL cameraGranted) { - if (!cameraGranted) { - OWSLogWarn(@"camera permission denied."); - return; - } - [self ows_askForMicrophonePermissions:^(BOOL micGranted) { - if (!micGranted) { - OWSLogWarn(@"proceeding, though mic permission denied."); - // We can still continue without mic permissions, but any captured video will - // be silent. - } - - UIViewController *pickerModal; - - if (SSKFeatureFlags.useCustomPhotoCapture) { - SendMediaNavigationController *navController = [SendMediaNavigationController showingCameraFirst]; - navController.sendMediaNavDelegate = self; - pickerModal = navController; - } else { - UIImagePickerController *picker = [OWSImagePickerController new]; - pickerModal = picker; - picker.sourceType = UIImagePickerControllerSourceTypeCamera; - picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; - picker.allowsEditing = NO; - picker.delegate = self; - } - OWSAssertDebug(pickerModal); - - [self dismissKeyBoard]; - pickerModal.modalPresentationStyle = UIModalPresentationFullScreen; - [self presentViewController:pickerModal animated:YES completion:nil]; - }]; - }]; -} - -- (void)chooseFromLibraryAsDocument -{ - OWSAssertIsOnMainThread(); - - [self chooseFromLibraryAsDocument:YES]; -} - -- (void)chooseFromLibraryAsMedia -{ - OWSAssertIsOnMainThread(); - - [self chooseFromLibraryAsDocument:NO]; -} - -- (void)chooseFromLibraryAsDocument:(BOOL)shouldTreatAsDocument -{ - OWSAssertIsOnMainThread(); - - self.isPickingMediaAsDocument = shouldTreatAsDocument; - - [self ows_askForMediaLibraryPermissions:^(BOOL granted) { - if (!granted) { - OWSLogWarn(@"Media Library permission denied."); - return; - } - - SendMediaNavigationController *pickerModal = [SendMediaNavigationController showingMediaLibraryFirst]; - pickerModal.sendMediaNavDelegate = self; - - [self dismissKeyBoard]; - pickerModal.modalPresentationStyle = UIModalPresentationFullScreen; - [self presentViewController:pickerModal animated:YES completion:nil]; - }]; -} - -/* - * Dismissing UIImagePickerController - */ - -- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker -{ - [self dismissViewControllerAnimated:YES completion:nil]; -} - -- (void)resetFrame -{ - // fixes bug on frame being off after this selection - CGRect frame = [UIScreen mainScreen].bounds; - self.view.frame = frame; -} - -#pragma mark - SendMediaNavDelegate - -- (void)sendMediaNavDidCancel:(SendMediaNavigationController *)sendMediaNavigationController -{ - [self dismissViewControllerAnimated:YES completion:^{ - if (!self.isFirstResponder) { - [self becomeFirstResponder]; - } - }]; -} - -- (void)sendMediaNav:(SendMediaNavigationController *)sendMediaNavigationController - didApproveAttachments:(NSArray *)attachments - messageText:(nullable NSString *)messageText -{ - [self tryToSendAttachments:attachments messageText:messageText]; - [self.inputToolbar clearTextMessageAnimated:NO]; - [self resetMentions]; - - // we want to already be at the bottom when the user returns, rather than have to watch - // the new message scroll into view. - [self scrollToBottomAnimated:NO]; - - [self dismissViewControllerAnimated:YES completion:^{ - if (!self.isFirstResponder) { - [self becomeFirstResponder]; - } - if (@available(iOS 10, *)) { - // do nothing - } else { - [self reloadInputViews]; - } - }]; -} - -- (nullable NSString *)sendMediaNavInitialMessageText:(SendMediaNavigationController *)sendMediaNavigationController -{ - return self.inputToolbar.messageText; -} - -- (void)sendMediaNav:(SendMediaNavigationController *)sendMediaNavigationController - didChangeMessageText:(nullable NSString *)messageText -{ - [self.inputToolbar setMessageText:messageText animated:NO]; -} - -#pragma mark - UIImagePickerControllerDelegate - -/* - * Fetching data from UIImagePickerController - */ -- (void)imagePickerController:(UIImagePickerController *)picker - didFinishPickingMediaWithInfo:(NSDictionary *)info -{ - [self resetFrame]; - - NSURL *referenceURL = [info valueForKey:UIImagePickerControllerReferenceURL]; - if (!referenceURL) { - OWSLogVerbose(@"Could not retrieve reference URL for picked asset"); - [self imagePickerController:picker didFinishPickingMediaWithInfo:info filename:nil]; - return; - } - - ALAssetsLibraryAssetForURLResultBlock resultblock = ^(ALAsset *imageAsset) { - ALAssetRepresentation *imageRep = [imageAsset defaultRepresentation]; - NSString *filename = [imageRep filename]; - [self imagePickerController:picker didFinishPickingMediaWithInfo:info filename:filename]; - }; - - ALAssetsLibrary *assetslibrary = [[ALAssetsLibrary alloc] init]; - [assetslibrary assetForURL:referenceURL - resultBlock:resultblock - failureBlock:^(NSError *error) { - OWSCFailDebug(@"Error retrieving filename for asset: %@", error); - }]; -} - -- (void)imagePickerController:(UIImagePickerController *)picker - didFinishPickingMediaWithInfo:(NSDictionary *)info - filename:(NSString *_Nullable)filename -{ - OWSAssertIsOnMainThread(); - - void (^failedToPickAttachment)(NSError *error) = ^void(NSError *error) { - OWSLogError(@"failed to pick attachment with error: %@", error); - }; - - NSString *mediaType = info[UIImagePickerControllerMediaType]; - if ([mediaType isEqualToString:(__bridge NSString *)kUTTypeMovie]) { - // Video picked from library or captured with camera - - NSURL *videoURL = info[UIImagePickerControllerMediaURL]; - [self dismissViewControllerAnimated:YES - completion:^{ - [self showApprovalDialogAfterProcessingVideoURL:videoURL filename:filename]; - }]; - } else if (picker.sourceType == UIImagePickerControllerSourceTypeCamera) { - // Static Image captured from camera - - UIImage *imageFromCamera = [info[UIImagePickerControllerOriginalImage] normalizedImage]; - - [self dismissViewControllerAnimated:YES - completion:^{ - OWSAssertIsOnMainThread(); - - if (imageFromCamera) { - // "Camera" attachments _SHOULD_ be resized, if possible. - SignalAttachment *attachment = - [SignalAttachment imageAttachmentWithImage:imageFromCamera - dataUTI:(NSString *)kUTTypeJPEG - filename:filename - imageQuality:TSImageQualityCompact]; - if (!attachment || [attachment hasError]) { - OWSLogWarn(@"Invalid attachment: %@.", - attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - failedToPickAttachment(nil); - } else { - [self showApprovalDialogForAttachment:attachment]; - } - } else { - failedToPickAttachment(nil); - } - }]; - } else { - // Non-Video image picked from library - OWSFailDebug( - @"Only use UIImagePicker for camera/video capture. Picking media from UIImagePicker is not supported. "); - - // To avoid re-encoding GIF and PNG's as JPEG we have to get the raw data of - // the selected item vs. using the UIImagePickerControllerOriginalImage - NSURL *assetURL = info[UIImagePickerControllerReferenceURL]; - PHAsset *asset = [[PHAsset fetchAssetsWithALAssetURLs:@[ assetURL ] options:nil] lastObject]; - if (!asset) { - return failedToPickAttachment(nil); - } - - // Images chosen from the "attach document" UI should be sent as originals; - // images chosen from the "attach media" UI should be resized to "medium" size; - TSImageQuality imageQuality = (self.isPickingMediaAsDocument ? TSImageQualityOriginal : TSImageQualityMedium); - - PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; - options.synchronous = YES; // We're only fetching one asset. - options.networkAccessAllowed = YES; // iCloud OK - options.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; // Don't need quick/dirty version - [[PHImageManager defaultManager] - requestImageDataForAsset:asset - options:options - resultHandler:^(NSData *_Nullable imageData, - NSString *_Nullable dataUTI, - UIImageOrientation orientation, - NSDictionary *_Nullable assetInfo) { - NSError *assetFetchingError = assetInfo[PHImageErrorKey]; - if (assetFetchingError || !imageData) { - return failedToPickAttachment(assetFetchingError); - } - OWSAssertIsOnMainThread(); - - DataSource *_Nullable dataSource = - [DataSourceValue dataSourceWithData:imageData utiType:dataUTI]; - [dataSource setSourceFilename:filename]; - SignalAttachment *attachment = [SignalAttachment attachmentWithDataSource:dataSource - dataUTI:dataUTI - imageQuality:imageQuality]; - [self dismissViewControllerAnimated:YES - completion:^{ - OWSAssertIsOnMainThread(); - if (!attachment || [attachment hasError]) { - OWSLogWarn(@"Invalid attachment: %@.", - attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - failedToPickAttachment(nil); - } else { - [self showApprovalDialogForAttachment:attachment]; - } - }]; - }]; - } -} - -- (void)showApprovalDialogAfterProcessingVideoURL:(NSURL *)movieURL filename:(nullable NSString *)filename -{ - OWSAssertIsOnMainThread(); - - [ModalActivityIndicatorViewController - presentFromViewController:self - canCancel:YES - message:nil - backgroundBlock:^(ModalActivityIndicatorViewController *modalActivityIndicator) { - DataSource *dataSource = - [DataSourcePath dataSourceWithURL:movieURL shouldDeleteOnDeallocation:NO]; - dataSource.sourceFilename = filename; - VideoCompressionResult *compressionResult = - [SignalAttachment compressVideoAsMp4WithDataSource:dataSource - dataUTI:(NSString *)kUTTypeMPEG4]; - - [compressionResult.attachmentPromise.then(^(SignalAttachment *attachment) { - OWSAssertIsOnMainThread(); - OWSAssertDebug([attachment isKindOfClass:[SignalAttachment class]]); - - if (modalActivityIndicator.wasCancelled) { - return; - } - - [modalActivityIndicator dismissWithCompletion:^{ - if (!attachment || [attachment hasError]) { - OWSLogError(@"Invalid attachment: %@.", - attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - } else { - [self showApprovalDialogForAttachment:attachment]; - } - }]; - }) retainUntilComplete]; - }]; -} - -#pragma mark - Storage access - -- (YapDatabaseConnection *)uiDatabaseConnection -{ - return OWSPrimaryStorage.sharedManager.uiDatabaseConnection; -} - -- (YapDatabaseConnection *)editingDatabaseConnection -{ - return OWSPrimaryStorage.sharedManager.dbReadWriteConnection; -} - -#pragma mark - Audio - -- (void)requestRecordingVoiceMemo -{ - OWSAssertIsOnMainThread(); - - NSUUID *voiceMessageUUID = [NSUUID UUID]; - self.voiceMessageUUID = voiceMessageUUID; - - __weak typeof(self) weakSelf = self; - [self ows_askForMicrophonePermissions:^(BOOL granted) { - __strong typeof(self) strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - if (strongSelf.voiceMessageUUID != voiceMessageUUID) { - // This voice message recording has been cancelled - // before recording could begin. - return; - } - - if (granted) { - [strongSelf startRecordingVoiceMemo]; - } else { - OWSLogInfo(@"we do not have recording permission."); - [strongSelf cancelVoiceMemo]; - [OWSAlerts showNoMicrophonePermissionAlert]; - } - }]; -} - -- (void)startRecordingVoiceMemo -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"startRecordingVoiceMemo"); - - // Cancel any ongoing audio playback. - [self.audioAttachmentPlayer stop]; - self.audioAttachmentPlayer = nil; - - NSString *temporaryDirectory = OWSTemporaryDirectory(); - NSString *filename = [NSString stringWithFormat:@"%lld.m4a", [NSDate ows_millisecondTimeStamp]]; - NSString *filepath = [temporaryDirectory stringByAppendingPathComponent:filename]; - NSURL *fileURL = [NSURL fileURLWithPath:filepath]; - - // Setup audio session - BOOL configuredAudio = [self.audioSession startAudioActivity:self.recordVoiceNoteAudioActivity]; - if (!configuredAudio) { - OWSFailDebug(@"Couldn't configure audio session"); - [self cancelVoiceMemo]; - return; - } - - NSError *error; - // Initiate and prepare the recorder - self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:fileURL - settings:@{ - AVFormatIDKey : @(kAudioFormatMPEG4AAC), - AVSampleRateKey : @(44100), - AVNumberOfChannelsKey : @(2), - AVEncoderBitRateKey : @(128 * 1024), - } - error:&error]; - - __weak ConversationViewController *weakSelf = self; - self.audioTimer = [NSTimer scheduledTimerWithTimeInterval:60 repeats:NO block:^(NSTimer *timer) { - [[weakSelf inputToolbar] hideVoiceMemoUI:YES]; - [weakSelf endRecordingVoiceMemo]; - }]; - - if (error) { - OWSFailDebug(@"Couldn't create audioRecorder: %@", error); - [self cancelVoiceMemo]; - return; - } - - self.audioRecorder.meteringEnabled = YES; - - if (![self.audioRecorder prepareToRecord]) { - OWSFailDebug(@"audioRecorder couldn't prepareToRecord."); - [self cancelVoiceMemo]; - return; - } - - if (![self.audioRecorder record]) { - OWSFailDebug(@"audioRecorder couldn't record."); - [self cancelVoiceMemo]; - return; - } -} - -- (void)endRecordingVoiceMemo -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"endRecordingVoiceMemo"); - - [self.audioTimer invalidate]; - - self.voiceMessageUUID = nil; - - if (!self.audioRecorder) { - // No voice message recording is in progress. - // We may be cancelling before the recording could begin. - OWSLogError(@"Missing audioRecorder"); - return; - } - - NSTimeInterval durationSeconds = self.audioRecorder.currentTime; - - [self stopRecording]; - - const NSTimeInterval kMinimumRecordingTimeSeconds = 1.f; - if (durationSeconds < kMinimumRecordingTimeSeconds) { - OWSLogInfo(@"Discarding voice message; too short."); - self.audioRecorder = nil; - - [self dismissKeyBoard]; - - [OWSAlerts - showAlertWithTitle: - NSLocalizedString(@"VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", - @"Title for the alert indicating the 'voice message' needs to be held to be held down to record.") - message:NSLocalizedString(@"VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", - @"Message for the alert indicating the 'voice message' needs to be held to be held " - @"down to record.")]; - return; - } - - DataSource *_Nullable dataSource = - [DataSourcePath dataSourceWithURL:self.audioRecorder.url shouldDeleteOnDeallocation:YES]; - self.audioRecorder = nil; - - if (!dataSource) { - OWSFailDebug(@"Couldn't load audioRecorder data"); - self.audioRecorder = nil; - return; - } - - NSString *filename = [NSLocalizedString(@"VOICE_MESSAGE_FILE_NAME", @"Filename for voice messages.") - stringByAppendingPathExtension:@"m4a"]; - [dataSource setSourceFilename:filename]; - SignalAttachment *attachment = - [SignalAttachment voiceMessageAttachmentWithDataSource:dataSource dataUTI:(NSString *)kUTTypeMPEG4Audio]; - OWSLogVerbose(@"voice memo duration: %f, file size: %zd", durationSeconds, [dataSource dataLength]); - if (!attachment || [attachment hasError]) { - OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - } else { - [self tryToSendAttachments:@[ attachment ] messageText:nil]; - } -} - -- (void)stopRecording -{ - [self.audioRecorder stop]; - [self.audioSession endAudioActivity:self.recordVoiceNoteAudioActivity]; -} - -- (void)cancelRecordingVoiceMemo -{ - OWSAssertIsOnMainThread(); - OWSLogDebug(@"cancelRecordingVoiceMemo"); - - [self.audioTimer invalidate]; - [self stopRecording]; - self.audioRecorder = nil; - self.voiceMessageUUID = nil; -} - -- (void)setAudioRecorder:(nullable AVAudioRecorder *)audioRecorder -{ - // Prevent device from sleeping while recording a voice message. - if (audioRecorder) { - [DeviceSleepManager.sharedInstance addBlockWithBlockObject:audioRecorder]; - } else if (_audioRecorder) { - [DeviceSleepManager.sharedInstance removeBlockWithBlockObject:_audioRecorder]; - } - - _audioRecorder = audioRecorder; -} - -#pragma mark Accessory View - -- (void)attachmentButtonPressed -{ - [self dismissKeyBoard]; - - __weak ConversationViewController *weakSelf = self; - if ([self isBlockedConversation]) { - [self showUnblockConversationUI:^(BOOL isBlocked) { - if (!isBlocked) { - [weakSelf attachmentButtonPressed]; - } - }]; - return; - } - - UIAlertController *actionSheet = - [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - - [actionSheet addAction:[OWSAlerts cancelAction]]; - - UIAlertAction *takeMediaAction = - [UIAlertAction actionWithTitle:NSLocalizedString( - @"MEDIA_FROM_CAMERA_BUTTON", @"media picker option to take photo or video") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_camera") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - [self takePictureOrVideo]; - }]; - UIImage *takeMediaImage = [UIImage imageNamed:@"actionsheet_camera_black"]; - OWSAssertDebug(takeMediaImage); - [takeMediaAction setValue:takeMediaImage forKey:@"image"]; - [actionSheet addAction:takeMediaAction]; - - UIAlertAction *chooseMediaAction = - [UIAlertAction actionWithTitle:NSLocalizedString( - @"MEDIA_FROM_LIBRARY_BUTTON", @"media picker option to choose from library") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_choose_media") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - [self chooseFromLibraryAsMedia]; - }]; - UIImage *chooseMediaImage = [UIImage imageNamed:@"actionsheet_camera_roll_black"]; - OWSAssertDebug(chooseMediaImage); - [chooseMediaAction setValue:chooseMediaImage forKey:@"image"]; - [actionSheet addAction:chooseMediaAction]; - - UIAlertAction *gifAction = - [UIAlertAction actionWithTitle:NSLocalizedString(@"SELECT_GIF_BUTTON", - @"Label for 'select GIF to attach' action sheet button") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_gif") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults; - BOOL hasSeenGIFMetadataWarning = [userDefaults boolForKey:@"hasSeenGIFMetadataWarning"]; - if (!hasSeenGIFMetadataWarning) { - [self showGIFMetadataWarning]; - [userDefaults setBool:YES forKey:@"hasSeenGIFMetadataWarning"]; - } else { - [self showGifPicker]; - } - }]; - UIImage *gifImage = [UIImage imageNamed:@"actionsheet_gif_black"]; - OWSAssertDebug(gifImage); - [gifAction setValue:gifImage forKey:@"image"]; - [actionSheet addAction:gifAction]; - - UIAlertAction *chooseDocumentAction = - [UIAlertAction actionWithTitle:NSLocalizedString(@"MEDIA_FROM_DOCUMENT_PICKER_BUTTON", - @"action sheet button title when choosing attachment type") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_document") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - [self showAttachmentDocumentPickerMenu]; - }]; - UIImage *chooseDocumentImage = [UIImage imageNamed:@"actionsheet_document_black"]; - OWSAssertDebug(chooseDocumentImage); - [chooseDocumentAction setValue:chooseDocumentImage forKey:@"image"]; - [actionSheet addAction:chooseDocumentAction]; - - /* - if (kIsSendingContactSharesEnabled) { - UIAlertAction *chooseContactAction = - [UIAlertAction actionWithTitle:NSLocalizedString(@"ATTACHMENT_MENU_CONTACT_BUTTON", - @"attachment menu option to send contact") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_contact") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - [self chooseContactForSending]; - }]; - UIImage *chooseContactImage = [UIImage imageNamed:@"actionsheet_contact"]; - OWSAssertDebug(takeMediaImage); - [chooseContactAction setValue:chooseContactImage forKey:@"image"]; - [actionSheet addAction:chooseContactAction]; - } - */ - - [self dismissKeyBoard]; - [self presentAlert:actionSheet]; -} - -- (nullable NSIndexPath *)lastVisibleIndexPath -{ - NSIndexPath *_Nullable lastVisibleIndexPath = nil; - for (NSIndexPath *indexPath in [self.collectionView indexPathsForVisibleItems]) { - if (!lastVisibleIndexPath || indexPath.row > lastVisibleIndexPath.row) { - lastVisibleIndexPath = indexPath; - } - } - if (lastVisibleIndexPath && lastVisibleIndexPath.row >= (NSInteger)self.viewItems.count) { - return (self.viewItems.count > 0 ? [NSIndexPath indexPathForRow:(NSInteger)self.viewItems.count - 1 inSection:0] - : nil); - } - return lastVisibleIndexPath; -} - -- (nullable id)lastVisibleViewItem -{ - NSIndexPath *_Nullable lastVisibleIndexPath = [self lastVisibleIndexPath]; - if (!lastVisibleIndexPath) { - return nil; - } - return [self viewItemForIndex:lastVisibleIndexPath.row]; -} - -// In the case where we explicitly scroll to bottom, we want to synchronously -// update the UI to reflect that, since the "mark as read" logic is asynchronous -// and won't update the UI state immediately. -- (void)didScrollToBottom -{ - id _Nullable lastVisibleViewItem = [self.viewItems lastObject]; - if (lastVisibleViewItem) { - uint64_t lastVisibleSortId = lastVisibleViewItem.interaction.sortId; - self.lastVisibleSortId = MAX(self.lastVisibleSortId, lastVisibleSortId); - } - - self.scrollDownButton.hidden = YES; - - self.hasUnreadMessages = NO; - - if (lastVisibleViewItem != NULL && self.lastVisibleSortId > 0) { - [OWSReadReceiptManager.sharedManager markAsReadLocallyBeforeSortId:self.lastVisibleSortId thread:self.thread]; - } -} - -- (void)updateLastVisibleSortId -{ - id _Nullable lastVisibleViewItem = [self lastVisibleViewItem]; - if (lastVisibleViewItem) { - uint64_t lastVisibleSortId = lastVisibleViewItem.interaction.sortId; - self.lastVisibleSortId = MAX(self.lastVisibleSortId, lastVisibleSortId); - } - - [self ensureScrollDownButton]; - - __block NSUInteger numberOfUnreadMessages; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - numberOfUnreadMessages = - [[transaction ext:TSUnreadDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; - }]; - self.hasUnreadMessages = numberOfUnreadMessages > 0; -} - -- (void)markVisibleMessagesAsRead -{ - if (self.presentedViewController) { - OWSLogInfo(@"Not marking messages as read; another view is presented."); - return; - } - if (OWSWindowManager.sharedManager.shouldShowCallView) { - OWSLogInfo(@"Not marking messages as read; call view is presented."); - return; - } - if (self.navigationController.topViewController != self) { - OWSLogInfo(@"Not marking messages as read; another view is pushed."); - return; - } - - [self updateLastVisibleSortId]; - - uint64_t lastVisibleSortId = self.lastVisibleSortId; - - if (lastVisibleSortId == 0) { - // No visible messages yet. New Thread. - return; - } - - [OWSReadReceiptManager.sharedManager markAsReadLocallyBeforeSortId:self.lastVisibleSortId thread:self.thread]; -} - -- (void)updateGroupModelTo:(TSGroupModel *)newGroupModel successCompletion:(void (^_Nullable)(void))successCompletion -{ - -} - -- (void)popKeyBoard -{ - [self.inputToolbar beginEditingTextMessage]; -} - -- (void)dismissKeyBoard -{ - [self.inputToolbar endEditingTextMessage]; -} - -#pragma mark Drafts - -- (void)loadDraftInCompose -{ - OWSAssertIsOnMainThread(); - - __block NSString *draft; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - draft = [self.thread currentDraftWithTransaction:transaction]; - }]; - [self.inputToolbar setMessageText:draft animated:NO]; -} - -- (void)saveDraft -{ - if (!self.inputToolbar.hidden) { - __block TSThread *thread = _thread; - __block NSString *currentDraft = [self.inputToolbar messageText]; - - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [thread setDraft:currentDraft transaction:transaction]; - }]; - } -} - -#pragma mark 3D Touch Preview Actions - -- (NSArray> *)previewActionItems -{ - return @[]; -} - -#ifdef USE_DEBUG_UI -- (void)navigationTitleLongPressed:(UIGestureRecognizer *)gestureRecognizer -{ - if (gestureRecognizer.state == UIGestureRecognizerStateBegan) { - [DebugUITableViewController presentDebugUIForThread:self.thread fromViewController:self]; - } -} -#endif - -#pragma mark - ConversationInputTextViewDelegate - -- (void)textViewDidChange:(UITextView *)textView -{ - // Prepare - NSString *newText = textView.text; - // Typing indicators - if (newText.length > 0) { - [self.typingIndicators didStartTypingOutgoingInputInThread:self.thread]; - } - // Mentions - BOOL isBackspace = newText.length < self.oldText.length; - if (isBackspace) { - self.currentMentionStartIndex = -1; - [self.inputToolbar hideMentionCandidateSelectionView]; - NSArray *mentionsToRemove = [self.mentions filtered:^BOOL(LKMention *mention) { - return ![mention isContainedIn:newText]; - }]; - [self.mentions removeObjectsInArray:mentionsToRemove]; - } - if (newText.length > 0) { - NSUInteger lastCharacterIndex = newText.length - 1; - unichar lastCharacter = [newText characterAtIndex:lastCharacterIndex]; - // Check if there is a whitespace before '@' or the '@' is the first character - unichar secondToLastCharacter = ' '; - if (lastCharacterIndex > 0) { - secondToLastCharacter = [newText characterAtIndex:lastCharacterIndex - 1]; - } - if (lastCharacter == '@' && [NSCharacterSet.whitespaceAndNewlineCharacterSet characterIsMember:secondToLastCharacter]) { - NSArray *mentionCandidates = [LKMentionsManager getMentionCandidatesFor:@"" in:self.thread.uniqueId]; - self.currentMentionStartIndex = (NSInteger)lastCharacterIndex; - [self.inputToolbar showMentionCandidateSelectionViewFor:mentionCandidates in:self.thread]; - } else if ([NSCharacterSet.whitespaceAndNewlineCharacterSet characterIsMember:lastCharacter]) { - self.currentMentionStartIndex = -1; - [self.inputToolbar hideMentionCandidateSelectionView]; - } else { - if (self.currentMentionStartIndex != -1) { - NSString *query = [newText substringFromIndex:(NSUInteger)self.currentMentionStartIndex + 1]; // + 1 to get rid of the @ - NSArray *mentionCandidates = [LKMentionsManager getMentionCandidatesFor:query in:self.thread.uniqueId]; - [self.inputToolbar showMentionCandidateSelectionViewFor:mentionCandidates in:self.thread]; - } - } - } - self.oldText = newText; -} - -- (void)handleMentionCandidateSelected:(LKMention *)mentionCandidate from:(LKMentionCandidateSelectionView *)mentionCandidateSelectionView -{ - NSUInteger mentionStartIndex = (NSUInteger)self.currentMentionStartIndex; - [self.mentions addObject:mentionCandidate]; - NSString *oldText = self.inputToolbar.messageText; - NSString *newText = [oldText stringByReplacingCharactersInRange:NSMakeRange(mentionStartIndex, oldText.length - mentionStartIndex) withString:[NSString stringWithFormat:@"@%@ ", mentionCandidate.displayName]]; - [self.inputToolbar setMessageText:newText animated:NO]; - self.currentMentionStartIndex = -1; - [self.inputToolbar hideMentionCandidateSelectionView]; - self.oldText = newText; -} - -- (NSString *)getSendText -{ - NSString *result = self.inputToolbar.messageText; - for (LKMention *mention in self.mentions) { - NSRange range = [result rangeOfString:[NSString stringWithFormat:@"@%@", mention.displayName]]; - result = [result stringByReplacingCharactersInRange:range withString:[[NSString alloc] initWithFormat:@"@%@", mention.publicKey]]; - } - return result; -} - -- (void)resetMentions -{ - self.oldText = @""; - self.currentMentionStartIndex = -1; - self.mentions = @[].mutableCopy; -} - -- (void)inputTextViewSendMessagePressed -{ - [self sendButtonPressed]; -} - -- (void)didPasteAttachment:(SignalAttachment *_Nullable)attachment -{ - OWSLogError(@""); - - [self showApprovalDialogForAttachment:attachment]; -} - -- (void)showApprovalDialogForAttachment:(SignalAttachment *_Nullable)attachment -{ - if (attachment == nil) { - OWSFailDebug(@"attachment was unexpectedly nil"); - [self showErrorAlertForAttachment:nil]; - return; - } - [self showApprovalDialogForAttachments:@[ attachment ]]; -} - -- (void)showApprovalDialogForAttachments:(NSArray *)attachments -{ - OWSNavigationController *modal = - [AttachmentApprovalViewController wrappedInNavControllerWithAttachments:attachments approvalDelegate:self]; - - [self presentViewController:modal animated:YES completion:nil]; -} - -- (void)tryToSendAttachments:(NSArray *)attachments messageText:(NSString *_Nullable)messageText -{ - DispatchMainThreadSafe(^{ - __weak ConversationViewController *weakSelf = self; - if ([self isBlockedConversation]) { - [self showUnblockConversationUI:^(BOOL isBlocked) { - if (!isBlocked) { - [weakSelf tryToSendAttachments:attachments messageText:messageText]; - } - }]; - return; - } - for (SignalAttachment *attachment in attachments) { - if ([attachment hasError]) { - OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - return; - } - } - SNVisibleMessage *message = [SNVisibleMessage new]; - message.text = messageText; - message.sentTimestamp = [NSDate millisecondTimestamp]; - TSThread *thread = self.thread; - TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [tsMessage saveWithTransaction:transaction]; - } completion:^{ - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [SNMessageSender send:message withAttachments:attachments inThread:thread usingTransaction:transaction]; - }]; - }]; - [self messageWasSent:tsMessage]; - }); -} - -- (void)keyboardWillShow:(NSNotification *)notification -{ - [self handleKeyboardNotification:notification]; -} - -- (void)keyboardDidShow:(NSNotification *)notification -{ - [self handleKeyboardNotification:notification]; -} - -- (void)keyboardWillHide:(NSNotification *)notification -{ - [self handleKeyboardNotification:notification]; -} - -- (void)keyboardDidHide:(NSNotification *)notification -{ - [self handleKeyboardNotification:notification]; -} - -- (void)keyboardWillChangeFrame:(NSNotification *)notification -{ - [self handleKeyboardNotification:notification]; -} - -- (void)keyboardDidChangeFrame:(NSNotification *)notification -{ - [self handleKeyboardNotification:notification]; -} - -- (void)handleKeyboardNotification:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - NSDictionary *userInfo = [notification userInfo]; - - NSValue *_Nullable keyboardBeginFrameValue = userInfo[UIKeyboardFrameBeginUserInfoKey]; - if (!keyboardBeginFrameValue) { - OWSFailDebug(@"Missing keyboard begin frame"); - return; - } - - NSValue *_Nullable keyboardEndFrameValue = userInfo[UIKeyboardFrameEndUserInfoKey]; - if (!keyboardEndFrameValue) { - OWSFailDebug(@"Missing keyboard end frame"); - return; - } - CGRect keyboardEndFrame = [keyboardEndFrameValue CGRectValue]; - CGRect keyboardEndFrameConverted = [self.view convertRect:keyboardEndFrame fromView:nil]; - - UIEdgeInsets oldInsets = self.collectionView.contentInset; - UIEdgeInsets newInsets = oldInsets; - - // Measures how far the keyboard "intrudes" into the collection view's content region. - // Indicates how large the bottom content inset should be in order to avoid the keyboard - // from hiding the conversation content. - // - // NOTE: we can ignore the "bottomLayoutGuide" (i.e. the notch); this will be accounted - // for by the "adjustedContentInset". - CGFloat keyboardContentOverlap - = MAX(0, self.view.height - self.bottomLayoutGuide.length - keyboardEndFrameConverted.origin.y); - - // For the sake of continuity, we want to maintain the same contentInsetBottom when the - // the keyboard/input accessory are hidden, e.g. during dismissal animations, when - // presenting popups like the attachment picker, etc. - // - // Therefore, we only zero out the contentInsetBottom if the inputAccessoryView is nil. - if (self.inputAccessoryView == nil || keyboardContentOverlap > 0) { - self.contentInsetBottom = keyboardContentOverlap; - } else if (!CurrentAppContext().isAppForegroundAndActive) { - // If app is not active, we'll dismiss the keyboard - // so only reserve enough space for the input accessory - // view. Otherwise, the content will animate into place - // when the app returns from the background. - // - // NOTE: There are two separate cases. If the keyboard is - // dismissed, the inputAccessoryView grows to allow - // space for the notch. In this case, we need to - // subtract bottomLayoutGuide. However, if the - // keyboard is presented we don't want to do that. - // I don't see a simple, safe way to distinguish - // these two cases. Therefore, I'm _always_ - // subtracting bottomLayoutGuide. This will cause - // a slight animation when returning to the app - // but it will "match" the presentation animation - // of the input accessory. - self.contentInsetBottom = MAX(0, self.inputAccessoryView.height - self.bottomLayoutGuide.length); - } - - newInsets.top = 0 + self.extraContentInsetPadding; - newInsets.bottom = self.contentInsetBottom + self.extraContentInsetPadding; - - BOOL wasScrolledToBottom = [self isScrolledToBottom]; - - void (^adjustInsets)(void) = ^(void) { - if (!UIEdgeInsetsEqualToEdgeInsets(self.collectionView.contentInset, newInsets)) { - self.collectionView.contentInset = newInsets; - } - self.collectionView.scrollIndicatorInsets = newInsets; - - // Note there is a bug in iOS11.2 which where switching to the emoji keyboard - // does not fire a UIKeyboardFrameWillChange notification. In that case, the scroll - // down button gets mostly obscured by the keyboard. - // RADAR: #36297652 - [self updateScrollDownButtonLayout]; - - // Update the layout of the scroll down button immediately. - // This change might be animated by the keyboard notification. - [self.scrollDownButton.superview layoutIfNeeded]; - - // Adjust content offset to prevent the presented keyboard from obscuring content. - if (!self.viewHasEverAppeared) { - [self scrollToDefaultPosition:NO]; - } else if (wasScrolledToBottom) { - // If we were scrolled to the bottom, don't do any fancy math. Just stay at the bottom. - [self scrollToBottomAnimated:NO]; - } else if (self.isViewCompletelyAppeared) { - // If we were scrolled away from the bottom, shift the content in lockstep with the - // keyboard, up to the limits of the content bounds. - CGFloat insetChange = newInsets.bottom - oldInsets.bottom; - CGFloat oldYOffset = self.collectionView.contentOffset.y; - CGFloat newYOffset = CGFloatClamp(oldYOffset + insetChange, 0, self.safeContentHeight); - CGPoint newOffset = CGPointMake(0, newYOffset); - - // If the user is dismissing the keyboard via interactive scrolling, any additional conset offset feels - // redundant, so we only adjust content offset when *presenting* the keyboard (i.e. when insetChange > 0). - if (insetChange > 0 && newYOffset > keyboardEndFrame.origin.y) { - [self.collectionView setContentOffset:newOffset animated:NO]; - } - } - }; - - if (self.shouldAnimateKeyboardChanges && CurrentAppContext().isAppForegroundAndActive) { - adjustInsets(); - } else { - // Even though we are scrolling without explicitly animating, the notification seems to occur within the context - // of a system animation, which is desirable when the view is visible, because the user sees the content rise - // in sync with the keyboard. However, when the view hasn't yet been presented, the animation conflicts and the - // result is that initial load causes the collection cells to visably "animate" to their final position once the - // view appears. - [UIView performWithoutAnimation:adjustInsets]; - } -} - -- (void)applyTheme -{ - OWSAssertIsOnMainThread(); - - // make sure toolbar extends below iPhoneX home button. - self.view.backgroundColor = Theme.toolbarBackgroundColor; - self.collectionView.backgroundColor = Theme.backgroundColor; -} - -#pragma mark - AttachmentApprovalViewControllerDelegate - -- (void)attachmentApproval:(AttachmentApprovalViewController *)attachmentApproval - didApproveAttachments:(NSArray *)attachments - messageText:(NSString *_Nullable)messageText -{ - for (SignalAttachment *attachment in attachments) { - if ([attachment hasError]) { - OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - return; - } - } - - [self tryToSendAttachments:attachments messageText:messageText]; - [self.inputToolbar clearTextMessageAnimated:NO]; - [self resetMentions]; - [self dismissViewControllerAnimated:YES completion:nil]; - - // We always want to scroll to the bottom of the conversation after the local user - // sends a message. Normally, this is taken care of in yapDatabaseModified:, but - // we don't listen to db modifications when this view isn't visible, i.e. when the - // attachment approval view is presented. - [self scrollToBottomAnimated:NO]; -} - -- (void)attachmentApprovalDidCancel:(AttachmentApprovalViewController *)attachmentApproval -{ - [self dismissViewControllerAnimated:YES completion:nil]; -} - -- (void)attachmentApproval:(AttachmentApprovalViewController *)attachmentApproval - didChangeMessageText:(nullable NSString *)newMessageText -{ - [self.inputToolbar setMessageText:newMessageText animated:NO]; -} - -#pragma mark - - -- (void)showErrorAlertForAttachment:(SignalAttachment *_Nullable)attachment -{ - OWSAssertDebug(attachment == nil || [attachment hasError]); - - NSString *errorMessage - = (attachment ? [attachment localizedErrorDescription] : [SignalAttachment missingDataErrorMessage]); - - OWSLogError(@": %@", errorMessage); - - [OWSAlerts showAlertWithTitle:NSLocalizedString( - @"ATTACHMENT_ERROR_ALERT_TITLE", @"The title of the 'attachment error' alert.") - message:errorMessage]; -} - -- (CGFloat)safeContentHeight -{ - // Don't use self.collectionView.contentSize.height as the collection view's - // content size might not be set yet. - // - // We can safely call prepareLayout to ensure the layout state is up-to-date - // since our layout uses a dirty flag internally to debounce redundant work. - [self.layout prepareLayout]; - return [self.collectionView.collectionViewLayout collectionViewContentSize].height; -} - -- (void)scrollToBottomAnimated:(BOOL)animated -{ - OWSAssertIsOnMainThread(); - - if (self.isUserScrolling) { - return; - } - - // Ensure the view is fully layed out before we try to scroll to the bottom, since - // we use the collectionView bounds to determine where the "bottom" is. - [self.view layoutIfNeeded]; - - const CGFloat topInset = ^{ - if (@available(iOS 11, *)) { - return -self.collectionView.adjustedContentInset.top; - } else { - return -self.collectionView.contentInset.top; - } - }(); - - const CGFloat bottomInset = ^{ - if (@available(iOS 11, *)) { - return -self.collectionView.adjustedContentInset.bottom; - } else { - return -self.collectionView.contentInset.bottom; - } - }(); - - const CGFloat firstContentPageTop = topInset; - const CGFloat collectionViewUnobscuredHeight = self.collectionView.bounds.size.height + bottomInset; - const CGFloat lastContentPageTop = self.safeContentHeight - collectionViewUnobscuredHeight; - - CGFloat dstY = MAX(firstContentPageTop, lastContentPageTop); - - [self.collectionView setContentOffset:CGPointMake(0, dstY) animated:animated]; - [self didScrollToBottom]; -} - -- (void)scrollToFirstUnreadMessage:(BOOL)isAnimated -{ - [self scrollToDefaultPosition:isAnimated]; -} - -#pragma mark - UIScrollViewDelegate - -- (void)updateLastKnownDistanceFromBottom -{ - // Never update the lastKnownDistanceFromBottom, - // if we're presenting the menu actions which - // temporarily meddles with the content insets. - if (!OWSWindowManager.sharedManager.isPresentingMenuActions) { - self.lastKnownDistanceFromBottom = @(self.safeDistanceFromBottom); - } -} - -- (void)scrollViewDidScroll:(UIScrollView *)scrollView -{ - // Constantly try to update the lastKnownDistanceFromBottom. - [self updateLastKnownDistanceFromBottom]; - - [self updateLastVisibleSortId]; - - [self.autoLoadMoreTimer invalidate]; - self.autoLoadMoreTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.1f - target:self - selector:@selector(autoLoadMoreTimerDidFire) - userInfo:nil - repeats:NO]; -} - -- (void)autoLoadMoreTimerDidFire -{ - [self autoLoadMoreIfNecessary]; -} - -- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView -{ - self.userHasScrolled = YES; - self.isUserScrolling = YES; -} - -- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate -{ - self.isUserScrolling = NO; -} - -#pragma mark - OWSConversationSettingsViewDelegate - -- (void)resendGroupUpdateForErrorMessage:(TSErrorMessage *)message -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug([_thread isKindOfClass:[TSGroupThread class]]); - OWSAssertDebug(message); - - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - TSGroupModel *groupModel = groupThread.groupModel; - [self updateGroupModelTo:groupModel - successCompletion:^{ - OWSLogInfo(@"Group updated, removing group creation error."); - - [message remove]; - }]; -} - -- (void)conversationColorWasUpdated -{ - [self.conversationStyle updateProperties]; - [self resetContentAndLayout]; -} - -- (void)groupWasUpdated:(TSGroupModel *)groupModel -{ - OWSAssertDebug(groupModel); - - NSMutableSet *groupMemberIds = [NSMutableSet setWithArray:groupModel.groupMemberIds]; - [groupMemberIds addObject:self.tsAccountManager.localNumber]; - groupModel.groupMemberIds = [NSMutableArray arrayWithArray:[groupMemberIds allObjects]]; - [self updateGroupModelTo:groupModel successCompletion:nil]; -} - -- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock -{ - if (self.presentedViewController) { - [self.presentedViewController dismissViewControllerAnimated:YES - completion:^{ - [self.navigationController - popToViewController:self - animated:YES - completion:completionBlock]; - }]; - } else { - [self.navigationController popToViewController:self animated:YES completion:completionBlock]; - } -} - -#pragma mark - Conversation Search - -- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController -{ - [self showSearchUI]; - [self popAllConversationSettingsViewsWithCompletion:^{ - // This delay is unfortunate, but without it, self.searchController.uiSearchController.searchBar - // isn't yet ready to become first responder. Presumably we're still mid transition. - // A hardcorded constant like this isn't great because it's either too slow, making our users - // wait, or too fast, and fails to wait long enough to be ready to become first responder. - // Luckily in this case the stakes aren't catastrophic. In the case that we're too aggressive - // the user will just have to manually tap into the search field before typing. - - // Leaving this assert in as proof that we're not ready to become first responder yet. - // If this assert fails, *great* maybe we can get rid of this delay. - OWSAssertDebug(![self.searchController.uiSearchController.searchBar canBecomeFirstResponder]); - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self.searchController.uiSearchController.searchBar becomeFirstResponder]; - }); - }]; -} - -- (void)showSearchUI -{ - self.isShowingSearchUI = YES; - - UISearchBar *searchBar = self.searchController.uiSearchController.searchBar; - - searchBar.searchBarStyle = UISearchBarStyleMinimal; - searchBar.barStyle = UIBarStyleBlack; - searchBar.tintColor = LKColors.accent; - UIImage *searchImage = [[UIImage imageNamed:@"searchbar_search"] asTintedImageWithColor:LKColors.searchBarPlaceholder]; - [searchBar setImage:searchImage forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; - UIImage *clearImage = [[UIImage imageNamed:@"searchbar_clear"] asTintedImageWithColor:LKColors.searchBarPlaceholder]; - [searchBar setImage:clearImage forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; - UITextField *searchTextField; - if (@available(iOS 13, *)) { - searchTextField = searchBar.searchTextField; - } else { - searchTextField = (UITextField *)[searchBar valueForKey:@"_searchField"]; - } - searchTextField.backgroundColor = LKColors.searchBarBackground; - searchTextField.textColor = LKColors.text; - searchTextField.attributedPlaceholder = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"Search", @"") attributes:@{ NSForegroundColorAttributeName : LKColors.searchBarPlaceholder }]; - searchBar.keyboardAppearance = LKAppModeUtilities.isLightMode ? UIKeyboardAppearanceDefault : UIKeyboardAppearanceDark; - [searchBar setPositionAdjustment:UIOffsetMake(4, 0) forSearchBarIcon:UISearchBarIconSearch]; - [searchBar setSearchTextPositionAdjustment:UIOffsetMake(2, 0)]; - [searchBar setPositionAdjustment:UIOffsetMake(-4, 0) forSearchBarIcon:UISearchBarIconClear]; - - // Note: setting a searchBar as the titleView causes UIKit to render the navBar - // *slightly* taller (44pt -> 56pt) - self.navigationItem.titleView = searchBar; - - [self updateBarButtonItems]; - - // Hack so that the ResultsBar stays on the screen when dismissing the search field - // keyboard. - // - // Details: - // - // When the search UI is activated, both the SearchField and the ConversationVC - // have the resultsBar as their inputAccessoryView. - // - // So when the SearchField is first responder, the ResultsBar is shown on top of the keyboard. - // When the ConversationVC is first responder, the ResultsBar is shown at the bottom of the - // screen. - // - // When the user swipes to dismiss the keyboard, trying to see more of the content while - // searching, we want the ResultsBar to stay at the bottom of the screen - that is, we - // want the ConversationVC to becomeFirstResponder. - // - // If the SearchField were a subview of ConversationVC.view, this would all be automatic, - // as first responder status is percolated up the responder chain via `nextResponder`, which - // basically travereses each superView, until you're at a rootView, at which point the next - // responder is the ViewController which controls that View. - // - // However, because SearchField lives in the Navbar, it's "controlled" by the - // NavigationController, not the ConversationVC. - // - // So here we stub the next responder on the navBar so that when the searchBar resigns - // first responder, the ConversationVC will be in it's responder chain - keeeping the - // ResultsBar on the bottom of the screen after dismissing the keyboard. - if (![self.navigationController.navigationBar isKindOfClass:[OWSNavigationBar class]]) { - OWSFailDebug(@"unexpected navigationController: %@", self.navigationController); - return; - } - OWSNavigationBar *navBar = (OWSNavigationBar *)self.navigationController.navigationBar; - navBar.stubbedNextResponder = self; -} - -- (void)hideSearchUI -{ - self.isShowingSearchUI = NO; - - self.navigationItem.titleView = self.headerView; - [self updateBarButtonItems]; - - if (![self.navigationController.navigationBar isKindOfClass:[OWSNavigationBar class]]) { - OWSFailDebug(@"unexpected navigationController: %@", self.navigationController); - return; - } - OWSNavigationBar *navBar = (OWSNavigationBar *)self.navigationController.navigationBar; - OWSAssertDebug(navBar.stubbedNextResponder == self); - navBar.stubbedNextResponder = nil; - - // restore first responder to VC - [self becomeFirstResponder]; - if (@available(iOS 10, *)) { - [self reloadInputViews]; - } else { - // We want to change the inputAccessoryView from SearchResults -> MessageInput - // reloading too soon on an old iOS9 device caused the inputAccessoryView to go from - // SearchResults -> MessageInput -> SearchResults - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self reloadInputViews]; - }); - } -} - -#pragma mark ConversationSearchControllerDelegate - -- (void)didDismissSearchController:(UISearchController *)searchController -{ - OWSLogVerbose(@""); - OWSAssertIsOnMainThread(); - [self hideSearchUI]; -} - -- (void)conversationSearchController:(ConversationSearchController *)conversationSearchController - didUpdateSearchResults:(nullable ConversationScreenSearchResultSet *)conversationScreenSearchResultSet -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"conversationScreenSearchResultSet: %@", conversationScreenSearchResultSet.debugDescription); - self.lastSearchedText = conversationScreenSearchResultSet.searchText; - [UIView performWithoutAnimation:^{ - [self.collectionView reloadItemsAtIndexPaths:self.collectionView.indexPathsForVisibleItems]; - }]; - if (conversationScreenSearchResultSet) { - [BenchManager completeEventWithEventId:self.lastSearchedText]; - } -} - -- (void)conversationSearchController:(ConversationSearchController *)conversationSearchController - didSelectMessageId:(NSString *)messageId -{ - OWSLogDebug(@"messageId: %@", messageId); - [self scrollToInteractionId:messageId]; - [BenchManager completeEventWithEventId:[NSString stringWithFormat:@"Conversation Search Nav: %@", messageId]]; -} - -- (void)scrollToInteractionId:(NSString *)interactionId -{ - NSIndexPath *_Nullable indexPath = [self.conversationViewModel ensureLoadWindowContainsInteractionId:interactionId]; - if (!indexPath) { - OWSFailDebug(@"unable to find indexPath"); - return; - } - - [self.collectionView scrollToItemAtIndexPath:indexPath - atScrollPosition:UICollectionViewScrollPositionCenteredVertically - animated:YES]; -} - -#pragma mark - ConversationViewLayoutDelegate - -- (NSArray> *)layoutItems -{ - return self.viewItems; -} - -- (CGFloat)layoutHeaderHeight -{ - return (self.showLoadMoreHeader ? kLoadMoreHeaderHeight : 0.f); -} - -#pragma mark - ConversationInputToolbarDelegate - -- (void)sendButtonPressed -{ - [BenchManager startEventWithTitle:@"Send Message" eventId:@"message-send"]; - [BenchManager startEventWithTitle:@"Send Message milestone: clearTextMessageAnimated completed" - eventId:@"fromSendUntil_clearTextMessageAnimated"]; - [BenchManager startEventWithTitle:@"Send Message milestone: toggleDefaultKeyboard completed" - eventId:@"fromSendUntil_toggleDefaultKeyboard"]; - - [self.inputToolbar hideMentionCandidateSelectionView]; - [self tryToSendTextMessage:[self getSendText] updateKeyboardState:YES]; -} - -- (void)tryToSendTextMessage:(NSString *)text updateKeyboardState:(BOOL)updateKeyboardState -{ - OWSAssertIsOnMainThread(); - __weak ConversationViewController *weakSelf = self; - if ([self isBlockedConversation]) { - [self showUnblockConversationUI:^(BOOL isBlocked) { - if (!isBlocked) { - [weakSelf tryToSendTextMessage:text updateKeyboardState:NO]; - } - }]; - return; - } - text = [text ows_stripped]; - if (text.length < 1) { return; } - SNVisibleMessage *message = [SNVisibleMessage new]; - message.sentTimestamp = [NSDate millisecondTimestamp]; - message.text = text; - message.quote = [SNQuote from:self.inputToolbar.quotedReply]; - OWSLinkPreviewDraft *linkPreviewDraft = self.inputToolbar.linkPreviewDraft; - TSThread *thread = self.thread; - TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread]; - [self.conversationViewModel appendUnsavedOutgoingTextMessage:tsMessage]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - message.linkPreview = [SNLinkPreview from:linkPreviewDraft using:transaction]; - } completion:^{ // Completes on the main queue - tsMessage.linkPreview = [OWSLinkPreview from:message.linkPreview]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [tsMessage saveWithTransaction:transaction]; - }]; - [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [SNMessageSender send:message withAttachments:@[] inThread:thread usingTransaction:transaction]; - [thread setDraft:@"" transaction:transaction]; - }]; - [self messageWasSent:tsMessage]; - [self.inputToolbar clearTextMessageAnimated:YES]; - [self resetMentions]; - dispatch_async(dispatch_get_main_queue(), ^{ - [[weakSelf inputToolbar] toggleDefaultKeyboard]; - }); - }]; -} - -- (void)voiceMemoGestureDidStart -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"voiceMemoGestureDidStart"); - - const CGFloat kIgnoreMessageSendDoubleTapDurationSeconds = 2.f; - if (self.lastMessageSentDate && - [[NSDate new] timeIntervalSinceDate:self.lastMessageSentDate] < kIgnoreMessageSendDoubleTapDurationSeconds) { - // If users double-taps the message send button, the second tap can look like a - // very short voice message gesture. We want to ignore such gestures. - [self.inputToolbar cancelVoiceMemoIfNecessary]; - [self.inputToolbar hideVoiceMemoUI:NO]; - [self cancelRecordingVoiceMemo]; - return; - } - - [self.inputToolbar showVoiceMemoUI]; - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); - [self requestRecordingVoiceMemo]; -} - -- (void)voiceMemoGestureDidComplete -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - - [self.inputToolbar hideVoiceMemoUI:YES]; - [self endRecordingVoiceMemo]; - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); -} - -- (void)voiceMemoGestureDidLock -{ - OWSAssertIsOnMainThread(); - OWSLogInfo(@""); - - [self.inputToolbar lockVoiceMemoUI]; -} - -- (void)voiceMemoGestureDidCancel -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"voiceMemoGestureDidCancel"); - - [self.inputToolbar hideVoiceMemoUI:NO]; - [self cancelRecordingVoiceMemo]; - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); -} - -- (void)voiceMemoGestureDidUpdateCancelWithRatioComplete:(CGFloat)cancelAlpha -{ - OWSAssertIsOnMainThread(); - - [self.inputToolbar setVoiceMemoUICancelAlpha:cancelAlpha]; -} - -- (void)cancelVoiceMemo -{ - OWSAssertIsOnMainThread(); - - [self.inputToolbar cancelVoiceMemoIfNecessary]; - [self.inputToolbar hideVoiceMemoUI:NO]; - [self cancelRecordingVoiceMemo]; -} - -#pragma mark - Database Observation - -- (void)setIsUserScrolling:(BOOL)isUserScrolling -{ - _isUserScrolling = isUserScrolling; - - [self autoLoadMoreIfNecessary]; -} - -- (void)setIsViewVisible:(BOOL)isViewVisible -{ - _isViewVisible = isViewVisible; - - [self updateCellsVisible]; -} - -- (void)updateCellsVisible -{ - BOOL isAppInBackground = CurrentAppContext().isInBackground; - BOOL isCellVisible = self.isViewVisible && !isAppInBackground; - for (ConversationViewCell *cell in self.collectionView.visibleCells) { - cell.isCellVisible = isCellVisible; - } -} - -- (nullable NSIndexPath *)firstIndexPathAtViewHorizonTimestamp -{ - if (!self.viewHorizonTimestamp) { - return nil; - } - if (self.viewItems.count < 1) { - return nil; - } - uint64_t viewHorizonTimestamp = self.viewHorizonTimestamp.unsignedLongLongValue; - // Binary search for the first view item whose timestamp >= the "view horizon" timestamp. - // We want to move "left" rightward, discarding interactions before this cutoff. - // We want to move "right" leftward, discarding all-but-the-first interaction after this cutoff. - // In the end, if we converge on an item _after_ this cutoff, it's the one we want. - // If we converge on an item _before_ this cutoff, there was no interaction that fit our criteria. - NSUInteger left = 0, right = self.viewItems.count - 1; - while (left != right) { - OWSAssertDebug(left < right); - NSUInteger mid = (left + right) / 2; - OWSAssertDebug(left <= mid); - OWSAssertDebug(mid < right); - id viewItem = self.viewItems[mid]; - if (viewItem.interaction.timestampForUI >= viewHorizonTimestamp) { - right = mid; - } else { - // This is an optimization; it also ensures that we converge. - left = mid + 1; - } - } - OWSAssertDebug(left == right); - id viewItem = self.viewItems[left]; - if (viewItem.interaction.timestampForUI >= viewHorizonTimestamp) { - OWSLogInfo(@"firstIndexPathAtViewHorizonTimestamp: %zd / %zd", left, self.viewItems.count); - return [NSIndexPath indexPathForRow:(NSInteger) left inSection:0]; - } else { - OWSLogInfo(@"firstIndexPathAtViewHorizonTimestamp: none / %zd", self.viewItems.count); - return nil; - } -} - -#pragma mark - ConversationCollectionViewDelegate - -- (void)collectionViewWillChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize -{ - OWSAssertIsOnMainThread(); -} - -- (void)collectionViewDidChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize -{ - OWSAssertIsOnMainThread(); - - if (oldSize.width != newSize.width) { - [self resetForSizeOrOrientationChange]; - } - - [self updateLastVisibleSortId]; -} - -#pragma mark - View Items - -- (nullable id)viewItemForIndex:(NSInteger)index -{ - if (index < 0 || index >= (NSInteger)self.viewItems.count) { - OWSFailDebug(@"Invalid view item index: %lu", (unsigned long)index); - return nil; - } - return self.viewItems[(NSUInteger)index]; -} - -#pragma mark - UICollectionViewDataSource - -- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section -{ - return (NSInteger)self.viewItems.count; -} - -- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView - cellForItemAtIndexPath:(NSIndexPath *)indexPath -{ - id _Nullable viewItem = [self viewItemForIndex:indexPath.row]; - ConversationViewCell *cell = [viewItem dequeueCellForCollectionView:self.collectionView indexPath:indexPath]; - if (!cell) { - OWSFailDebug(@"Could not dequeue cell."); - return cell; - } - cell.viewItem = viewItem; - cell.delegate = self; - if ([cell isKindOfClass:[OWSMessageCell class]]) { - OWSMessageCell *messageCell = (OWSMessageCell *)cell; - messageCell.messageBubbleView.delegate = self; - } - cell.conversationStyle = self.conversationStyle; - - [cell loadForDisplay]; - - // TODO: Confirm with nancy if this will work. - NSString *cellName = [NSString stringWithFormat:@"interaction.%@", NSUUID.UUID.UUIDString]; - cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, cellName); - - return cell; -} - -#pragma mark - UICollectionViewDelegate - -- (void)collectionView:(UICollectionView *)collectionView - willDisplayCell:(UICollectionViewCell *)cell - forItemAtIndexPath:(NSIndexPath *)indexPath -{ - OWSAssertDebug([cell isKindOfClass:[ConversationViewCell class]]); - - ConversationViewCell *conversationViewCell = (ConversationViewCell *)cell; - conversationViewCell.isCellVisible = YES; -} - -- (void)collectionView:(UICollectionView *)collectionView - didEndDisplayingCell:(nonnull UICollectionViewCell *)cell - forItemAtIndexPath:(nonnull NSIndexPath *)indexPath -{ - OWSAssertDebug([cell isKindOfClass:[ConversationViewCell class]]); - - ConversationViewCell *conversationViewCell = (ConversationViewCell *)cell; - conversationViewCell.isCellVisible = NO; -} - -// We use this hook to ensure scroll state continuity. As the collection -// view's content size changes, we want to keep the same cells in view. -- (CGPoint)collectionView:(UICollectionView *)collectionView - targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset -{ - if (@available(iOS 13, *)) { - - } else { - if (self.menuActionsViewController != nil) { - NSValue *_Nullable contentOffset = [self contentOffsetForMenuActionInteraction]; - if (contentOffset != nil) { - return contentOffset.CGPointValue; - } - } - } - - if (self.scrollContinuity == kScrollContinuityBottom && self.lastKnownDistanceFromBottom) { - NSValue *_Nullable contentOffset = - [self contentOffsetForLastKnownDistanceFromBottom:self.lastKnownDistanceFromBottom.floatValue]; - if (contentOffset) { - proposedContentOffset = contentOffset.CGPointValue; - } - } - - return proposedContentOffset; -} - -// We use this hook to ensure scroll state continuity. As the collection -// view's content size changes, we want to keep the same cells in view. -- (nullable NSValue *)contentOffsetForLastKnownDistanceFromBottom:(CGFloat)lastKnownDistanceFromBottom -{ - // Adjust the content offset to reflect the "last known" distance - // from the bottom of the content. - CGFloat contentOffsetYBottom = self.maxContentOffsetY; - CGFloat contentOffsetY = contentOffsetYBottom - MAX(0, lastKnownDistanceFromBottom); - CGFloat minContentOffsetY; - if (@available(iOS 11, *)) { - minContentOffsetY = -self.collectionView.safeAreaInsets.top; - } else { - minContentOffsetY = 0.f; - } - contentOffsetY = MAX(minContentOffsetY, contentOffsetY); - return [NSValue valueWithCGPoint:CGPointMake(0, contentOffsetY)]; -} - -#pragma mark - Scroll State - -- (BOOL)isScrolledToBottom -{ - CGFloat distanceFromBottom = self.safeDistanceFromBottom; - const CGFloat kIsAtBottomTolerancePts = 5; - BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts; - return isScrolledToBottom; -} - -- (CGFloat)safeDistanceFromBottom -{ - // This is a bit subtle. - // - // The _wrong_ way to determine if we're scrolled to the bottom is to - // measure whether the collection view's content is "near" the bottom edge - // of the collection view. This is wrong because the collection view - // might not have enough content to fill the collection view's bounds - // _under certain conditions_ (e.g. with the keyboard dismissed). - // - // What we're really interested in is something a bit more subtle: - // "Is the scroll view scrolled down as far as it can, "at rest". - // - // To determine that, we find the appropriate "content offset y" if - // the scroll view were scrolled down as far as possible. IFF the - // actual "content offset y" is "near" that value, we return YES. - CGFloat maxContentOffsetY = self.maxContentOffsetY; - CGFloat distanceFromBottom = maxContentOffsetY - self.collectionView.contentOffset.y; - return distanceFromBottom; -} - -- (CGFloat)maxContentOffsetY -{ - CGFloat contentHeight = self.safeContentHeight; - - UIEdgeInsets adjustedContentInset; - if (@available(iOS 11, *)) { - adjustedContentInset = self.collectionView.adjustedContentInset; - } else { - adjustedContentInset = self.collectionView.contentInset; - } - // Note the usage of MAX() to handle the case where there isn't enough - // content to fill the collection view at its current size. - CGFloat maxContentOffsetY = contentHeight + adjustedContentInset.bottom - self.collectionView.bounds.size.height; - return maxContentOffsetY; -} - -#pragma mark - Toast - -- (void)presentMissingQuotedReplyToast -{ - OWSLogInfo(@""); - - NSString *toastText = NSLocalizedString(@"QUOTED_REPLY_ORIGINAL_MESSAGE_DELETED", - @"Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of " - @"the message was since deleted."); - - ToastController *toastController = [[ToastController alloc] initWithText:toastText]; - - CGFloat bottomInset = kToastInset + self.collectionView.contentInset.bottom + self.view.layoutMargins.bottom; - - [toastController presentToastViewFromBottomOfView:self.view inset:bottomInset]; -} - -- (void)presentRemotelySourcedQuotedReplyToast -{ - OWSLogInfo(@""); - - NSString *toastText = NSLocalizedString(@"QUOTED_REPLY_ORIGINAL_MESSAGE_REMOTELY_SOURCED", - @"Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of " - @"the message didn't exist when the quote was received."); - - ToastController *toastController = [[ToastController alloc] initWithText:toastText]; - - CGFloat bottomInset = kToastInset + self.collectionView.contentInset.bottom + self.view.layoutMargins.bottom; - - [toastController presentToastViewFromBottomOfView:self.view inset:bottomInset]; -} - -#pragma mark - - -- (void)presentViewController:(UIViewController *)viewController - animated:(BOOL)animated - completion:(void (^__nullable)(void))completion -{ - // Ensure that we are first responder before presenting other views. - // This ensures that the input toolbar will be restored after the - // presented view is dismissed. - if (![self isFirstResponder]) { - [self becomeFirstResponder]; - } - - [super presentViewController:viewController animated:animated completion:completion]; -} - -#pragma mark - ConversationViewModelDelegate - -- (void)conversationViewModelWillUpdate -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.conversationViewModel); - - // HACK to work around radar #28167779 - // "UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout" - // more: https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue - // This was our #2 crash, and much exacerbated by the refactoring somewhere between 2.6.2.0-2.6.3.8 - // - // NOTE: It's critical we do this before beginLongLivedReadTransaction. - // We want to relayout our contents using the old message mappings and - // view items before they are updated. - [self.collectionView layoutIfNeeded]; - // ENDHACK to work around radar #28167779 -} - -- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(conversationUpdate); - OWSAssertDebug(self.conversationViewModel); - - if (!self.viewLoaded) { - // It's safe to ignore updates before the view loads; - // viewWillAppear will call resetContentAndLayout. - return; - } - - [self dismissMenuActionsIfNecessary]; - - if (self.isGroupConversation) { - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.thread reloadWithTransaction:transaction]; - }]; - } - - [self updateDisappearingMessagesConfiguration]; - - if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Minor) { - return; - } else if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Reload) { - [self resetContentAndLayout]; - [self updateLastVisibleSortId]; - return; - } - - OWSAssertDebug(conversationUpdate.conversationUpdateType == ConversationUpdateType_Diff); - OWSAssertDebug(conversationUpdate.updateItems); - - // We want to auto-scroll to the bottom of the conversation - // if the user is inserting new interactions. - __block BOOL scrollToBottom = NO; - - self.scrollContinuity = ([self isScrolledToBottom] ? kScrollContinuityBottom : kScrollContinuityTop); - - void (^batchUpdates)(void) = ^{ - OWSAssertIsOnMainThread(); - - const NSUInteger section = 0; - BOOL hasInserted = NO, hasUpdated = NO; - for (ConversationUpdateItem *updateItem in conversationUpdate.updateItems) { - switch (updateItem.updateItemType) { - case ConversationUpdateItemType_Delete: { - // Always perform deletes before inserts and updates. - OWSAssertDebug(!hasInserted && !hasUpdated); - [self.collectionView deleteItemsAtIndexPaths:@[ - [NSIndexPath indexPathForRow:(NSInteger)updateItem.oldIndex inSection:section] - ]]; - break; - } - case ConversationUpdateItemType_Insert: { - // Always perform inserts before updates. - OWSAssertDebug(!hasUpdated); - [self.collectionView insertItemsAtIndexPaths:@[ - [NSIndexPath indexPathForRow:(NSInteger)updateItem.newIndex inSection:section] - ]]; - hasInserted = YES; - - id viewItem = updateItem.viewItem; - OWSAssertDebug(viewItem); - if ([viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - if (!outgoingMessage.isFromLinkedDevice) { - scrollToBottom = YES; - } - } - - break; - } - case ConversationUpdateItemType_Update: { - [self.collectionView reloadItemsAtIndexPaths:@[ - [NSIndexPath indexPathForRow:(NSInteger)updateItem.oldIndex inSection:section] - ]]; - hasUpdated = YES; - break; - } - } - } - }; - - BOOL shouldAnimateUpdates = conversationUpdate.shouldAnimateUpdates; - void (^batchUpdatesCompletion)(BOOL) = ^(BOOL finished) { - OWSAssertIsOnMainThread(); - - if (!finished) { - OWSLogInfo(@"performBatchUpdates did not finish"); - } - - [self updateLastVisibleSortId]; - - if (scrollToBottom) { - [self scrollToBottomAnimated:NO]; - } - - // Try to update the lastKnownDistanceFromBottom; the content size may have changed. - [self updateLastKnownDistanceFromBottom]; - }; - - @try { - if (shouldAnimateUpdates) { - [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; - - } else { - // HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a - // UIKit Crash like: - // - // *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:], - // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760 - // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While - // preparing update a visible view at {length = 2, path = 0 - 142} - // wasn't found in the current data model and was not in an update animation. This is an internal - // error.' - // - // I'm unclear if this is a bug in UIKit, or if we're doing something crazy in - // ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the - // conversation. See `DebugUIMessages#thrashCellsInThread:` - [UIView - animateWithDuration:0.0 - animations:^{ - [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; - if (scrollToBottom) { - [self scrollToBottomAnimated:NO]; - } - [BenchManager completeEventWithEventId:@"message-send"]; - }]; - } - } @catch (NSException *exception) { - OWSFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", - exception.description, - exception.name, - exception.reason, - exception.userInfo); - - for (ConversationUpdateItem *updateItem in conversationUpdate.updateItems) { - switch (updateItem.updateItemType) { - case ConversationUpdateItemType_Delete: - OWSLogWarn(@"ConversationUpdateItemType_Delete class: %@, itemId: %@, oldIndex: %lu, " - @"newIndex: %lu", - [updateItem.viewItem class], - updateItem.viewItem.itemId, - (unsigned long)updateItem.oldIndex, - (unsigned long)updateItem.newIndex); - break; - case ConversationUpdateItemType_Insert: - OWSLogWarn(@"ConversationUpdateItemType_Insert class: %@, itemId: %@, oldIndex: %lu, " - @"newIndex: %lu", - [updateItem.viewItem class], - updateItem.viewItem.itemId, - (unsigned long)updateItem.oldIndex, - (unsigned long)updateItem.newIndex); - break; - case ConversationUpdateItemType_Update: - OWSLogWarn(@"ConversationUpdateItemType_Update class: %@, itemId: %@, oldIndex: %lu, " - @"newIndex: %lu", - [updateItem.viewItem class], - updateItem.viewItem.itemId, - (unsigned long)updateItem.oldIndex, - (unsigned long)updateItem.newIndex); - break; - } - } - - @throw exception; - } - - self.lastReloadDate = [NSDate new]; -} - -- (void)conversationViewModelWillLoadMoreItems -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.conversationViewModel); - - // We want to restore the current scroll state after we update the range, update - // the dynamic interactions and re-layout. Here we take a "before" snapshot. - self.scrollDistanceToBottomSnapshot = self.safeContentHeight - self.collectionView.contentOffset.y; -} - -- (void)conversationViewModelDidLoadMoreItems -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.conversationViewModel); - - [self.layout prepareLayout]; - - self.collectionView.contentOffset = CGPointMake(0, self.safeContentHeight - self.scrollDistanceToBottomSnapshot); -} - -- (void)conversationViewModelDidLoadPrevPage -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.conversationViewModel); - - [self scrollToUnreadIndicatorAnimated]; -} - -- (void)conversationViewModelRangeDidChange -{ - OWSAssertIsOnMainThread(); - - if (!self.conversationViewModel) { - return; - } - - [self updateShowLoadMoreHeader]; -} - -- (void)conversationViewModelDidReset -{ - OWSAssertIsOnMainThread(); - - // Scroll to bottom to get view back to a known good state. - [self scrollToBottomAnimated:NO]; -} - -#pragma mark - Orientation - -- (void)viewWillTransitionToSize:(CGSize)size - withTransitionCoordinator:(id)coordinator -{ - OWSAssertIsOnMainThread(); - - [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - - // The "message actions" window tries to pin the message - // in the content of this view. It's easier to dismiss the - // "message actions" window when the device changes orientation - // than to try to ensure this works in that case. - if (OWSWindowManager.sharedManager.isPresentingMenuActions) { - [self dismissMenuActions]; - } - - // Snapshot the "last visible row". - NSIndexPath *_Nullable lastVisibleIndexPath = self.lastVisibleIndexPath; - - __weak ConversationViewController *weakSelf = self; - [coordinator - animateAlongsideTransition:^(id context) { - if (lastVisibleIndexPath) { - [self.collectionView scrollToItemAtIndexPath:lastVisibleIndexPath - atScrollPosition:UICollectionViewScrollPositionBottom - animated:NO]; - } - } - completion:^(id context) { - ConversationViewController *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - // When transition animation is complete, update layout to reflect - // new size. - [strongSelf resetForSizeOrOrientationChange]; - - [strongSelf updateInputBarLayout]; - - if (self.menuActionsViewController != nil) { - [self scrollToMenuActionInteraction:NO]; - } else if (lastVisibleIndexPath) { - [strongSelf.collectionView scrollToItemAtIndexPath:lastVisibleIndexPath - atScrollPosition:UICollectionViewScrollPositionBottom - animated:NO]; - } - }]; -} - -- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection -{ - [super traitCollectionDidChange:previousTraitCollection]; - - [self ensureBannerState]; - [self updateBarButtonItems]; -} - -- (void)resetForSizeOrOrientationChange -{ - self.scrollContinuity = kScrollContinuityBottom; - - self.conversationStyle.viewWidth = self.collectionView.width; - // Evacuate cached cell sizes. - for (id viewItem in self.viewItems) { - [viewItem clearCachedLayoutState]; - } - [self.collectionView.collectionViewLayout invalidateLayout]; - [self.collectionView reloadData]; - if (self.viewHasEverAppeared) { - // Try to update the lastKnownDistanceFromBottom; the content size may have changed. - [self updateLastKnownDistanceFromBottom]; - } - [self updateInputBarLayout]; -} - -- (void)viewSafeAreaInsetsDidChange -{ - [super viewSafeAreaInsetsDidChange]; - - [self updateInputBarLayout]; -} - -- (void)updateInputBarLayout -{ - UIEdgeInsets safeAreaInsets = UIEdgeInsetsZero; - if (@available(iOS 11, *)) { - safeAreaInsets = self.view.safeAreaInsets; - } - [self.inputToolbar updateLayoutWithSafeAreaInsets:safeAreaInsets]; - - // Scroll button layout depends on input toolbar size. - [self updateScrollDownButtonLayout]; -} - -- (void)handleEncryptingMessageNotification:(NSNotification *)notification -{ - NSNumber *timestamp = (NSNumber *)notification.object; - [self setProgressIfNeededTo:0.25f forMessageWithTimestamp:timestamp]; -} - -- (void)handleCalculatingMessagePoWNotification:(NSNotification *)notification -{ - NSNumber *timestamp = (NSNumber *)notification.object; - [self setProgressIfNeededTo:0.50f forMessageWithTimestamp:timestamp]; -} - -- (void)handleMessageSendingNotification:(NSNotification *)notification -{ - NSNumber *timestamp = (NSNumber *)notification.object; - [self setProgressIfNeededTo:0.75f forMessageWithTimestamp:timestamp]; -} - -- (void)handleMessageSentNotification:(NSNotification *)notification -{ - NSNumber *timestamp = (NSNumber *)notification.object; - [self setProgressIfNeededTo:1.0f forMessageWithTimestamp:timestamp]; - [self.handledMessageTimestamps addObject:timestamp]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void) { - [self hideProgressIndicatorViewForMessageWithTimestamp:timestamp]; - }); -} - -- (void)handleMessageSendingFailedNotification:(NSNotification *)notification -{ - NSNumber *timestamp = (NSNumber *)notification.object; - self.progressIndicatorView.progressTintColor = LKColors.destructive; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void) { - [self hideProgressIndicatorViewForMessageWithTimestamp:timestamp]; - }); -} - -- (void)setProgressIfNeededTo:(float)progress forMessageWithTimestamp:(NSNumber *)timestamp -{ - if ([self.handledMessageTimestamps containsObject:timestamp]) { return; } - dispatch_async(dispatch_get_main_queue(), ^{ - __block TSInteraction *targetInteraction; - [LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.thread enumerateInteractionsWithTransaction:transaction usingBlock:^(TSInteraction *interaction, YapDatabaseReadTransaction *t) { - if (interaction.timestampForUI == timestamp.unsignedLongLongValue) { - targetInteraction = interaction; - } - }]; - }]; - if (targetInteraction == nil || targetInteraction.interactionType != OWSInteractionType_OutgoingMessage) { return; } - NSString *hexEncodedPublicKey = targetInteraction.thread.contactIdentifier; - if (hexEncodedPublicKey == nil) { return; } - if (progress <= self.progressIndicatorView.progress) { return; } - self.progressIndicatorView.alpha = 1; - [self.progressIndicatorView setProgress:progress animated:YES]; - }); -} - -- (void)hideProgressIndicatorViewForMessageWithTimestamp:(NSNumber *)timestamp -{ - __block TSInteraction *targetInteraction; - [self.thread enumerateInteractionsUsingBlock:^(TSInteraction *interaction) { - if (interaction.timestampForUI == timestamp.unsignedLongLongValue) { - targetInteraction = interaction; - } - }]; - if (targetInteraction == nil || targetInteraction.interactionType != OWSInteractionType_OutgoingMessage) { return; } - dispatch_async(dispatch_get_main_queue(), ^{ - [UIView animateWithDuration:0.25 animations:^{ - self.progressIndicatorView.alpha = 0; - } completion:^(BOOL finished) { - [self.progressIndicatorView setProgress:0.0f]; - self.progressIndicatorView.progressTintColor = LKColors.accent; - }]; - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewItem.h b/Session/Conversations/ConversationViewItem.h index 45f91a697..ea06f62aa 100644 --- a/Session/Conversations/ConversationViewItem.h +++ b/Session/Conversations/ConversationViewItem.h @@ -2,11 +2,12 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import "ConversationViewLayout.h" #import NS_ASSUME_NONNULL_BEGIN +extern NSString *const SNAudioDidFinishPlayingNotification; + typedef NS_ENUM(NSInteger, OWSMessageCellType) { OWSMessageCellType_Unknown, OWSMessageCellType_TextOnlyMessage, @@ -23,7 +24,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); @class ContactShareViewModel; @class ConversationViewCell; @class DisplayableText; -@class LKVoiceMessageView; +@class SNVoiceMessageView; @class OWSLinkPreview; @class OWSQuotedReplyModel; @class OWSUnreadIndicator; @@ -52,14 +53,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); #pragma mark - -// This is a ViewModel for cells in the conversation view. -// -// The lifetime of this class is the lifetime of that cell -// in the load window of the conversation view. -// -// Critically, this class implements ConversationViewLayoutItem -// and does caching of the cell's size. -@protocol ConversationViewItem +@protocol ConversationViewItem @property (nonatomic, readonly) TSInteraction *interaction; @@ -79,17 +73,16 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); @property (nonatomic, readonly) BOOL isExpiringMessage; @property (nonatomic) BOOL shouldShowDate; -@property (nonatomic) BOOL shouldShowSenderAvatar; +@property (nonatomic) BOOL shouldShowSenderProfilePicture; @property (nonatomic, nullable) NSAttributedString *senderName; @property (nonatomic) BOOL shouldHideFooter; @property (nonatomic) BOOL isFirstInCluster; +@property (nonatomic) BOOL isOnlyMessageInCluster; @property (nonatomic) BOOL isLastInCluster; +@property (nonatomic) BOOL wasPreviousItemInfoMessage; @property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator; -- (ConversationViewCell *)dequeueCellForCollectionView:(UICollectionView *)collectionView - indexPath:(NSIndexPath *)indexPath; - - (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction; - (void)clearCachedLayoutState; @@ -98,7 +91,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); #pragma mark - Audio Playback -@property (nonatomic, weak) LKVoiceMessageView *lastAudioMessageView; +@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView; @property (nonatomic, readonly) CGFloat audioDurationSeconds; @property (nonatomic, readonly) CGFloat audioProgressSeconds; @@ -157,13 +150,12 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); #pragma mark - @interface ConversationInteractionViewItem - : NSObject + : NSObject - (instancetype)init NS_UNAVAILABLE; - (instancetype)initWithInteraction:(TSInteraction *)interaction isGroupThread:(BOOL)isGroupThread - transaction:(YapDatabaseReadTransaction *)transaction - conversationStyle:(ConversationStyle *)conversationStyle; + transaction:(YapDatabaseReadTransaction *)transaction; @end diff --git a/Session/Conversations/ConversationViewItem.m b/Session/Conversations/ConversationViewItem.m index 75e2b9ceb..2b013ef21 100644 --- a/Session/Conversations/ConversationViewItem.m +++ b/Session/Conversations/ConversationViewItem.m @@ -4,22 +4,19 @@ #import #import "ConversationViewItem.h" - -#import "OWSMessageCell.h" -#import "OWSMessageHeaderView.h" -#import "OWSSystemMessageCell.h" #import "Session-Swift.h" #import "AnyPromise.h" #import #import #import - #import #import #import NS_ASSUME_NONNULL_BEGIN +NSString *const SNAudioDidFinishPlayingNotification = @"SNAudioDidFinishPlayingNotification"; + NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) { switch (cellType) { @@ -102,7 +99,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) @property (nonatomic, nullable) NSString *systemMessageText; @property (nonatomic, nullable) TSThread *incomingMessageAuthorThread; @property (nonatomic, nullable) NSString *authorConversationColorName; -@property (nonatomic, nullable) ConversationStyle *conversationStyle; @end @@ -111,13 +107,15 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) @implementation ConversationInteractionViewItem @synthesize shouldShowDate = _shouldShowDate; -@synthesize shouldShowSenderAvatar = _shouldShowSenderAvatar; +@synthesize shouldShowSenderProfilePicture = _shouldShowSenderProfilePicture; @synthesize unreadIndicator = _unreadIndicator; @synthesize didCellMediaFailToLoad = _didCellMediaFailToLoad; @synthesize interaction = _interaction; @synthesize isFirstInCluster = _isFirstInCluster; @synthesize isGroupThread = _isGroupThread; +@synthesize isOnlyMessageInCluster = _isOnlyMessageInCluster; @synthesize isLastInCluster = _isLastInCluster; +@synthesize wasPreviousItemInfoMessage = _wasPreviousItemInfoMessage; @synthesize lastAudioMessageView = _lastAudioMessageView; @synthesize senderName = _senderName; @synthesize shouldHideFooter = _shouldHideFooter; @@ -125,11 +123,9 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) - (instancetype)initWithInteraction:(TSInteraction *)interaction isGroupThread:(BOOL)isGroupThread transaction:(YapDatabaseReadTransaction *)transaction - conversationStyle:(ConversationStyle *)conversationStyle { OWSAssertDebug(interaction); OWSAssertDebug(transaction); - OWSAssertDebug(conversationStyle); self = [super init]; @@ -139,7 +135,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) _interaction = interaction; _isGroupThread = isGroupThread; - _conversationStyle = conversationStyle; [self ensureViewState:transaction]; @@ -228,13 +223,13 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) [self clearCachedLayoutState]; } -- (void)setShouldShowSenderAvatar:(BOOL)shouldShowSenderAvatar +- (void)setShouldShowSenderAvatar:(BOOL)shouldShowSenderProfilePicture { - if (_shouldShowSenderAvatar == shouldShowSenderAvatar) { + if (_shouldShowSenderProfilePicture == shouldShowSenderProfilePicture) { return; } - _shouldShowSenderAvatar = shouldShowSenderAvatar; + _shouldShowSenderProfilePicture = shouldShowSenderProfilePicture; [self clearCachedLayoutState]; } @@ -307,114 +302,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) return self.cachedCellSize != nil; } -- (CGSize)cellSize -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.conversationStyle); - - if (!self.cachedCellSize) { - ConversationViewCell *_Nullable measurementCell = [self measurementCell]; - measurementCell.viewItem = self; - measurementCell.conversationStyle = self.conversationStyle; - CGSize cellSize = [measurementCell cellSize]; - self.cachedCellSize = [NSValue valueWithCGSize:cellSize]; - [measurementCell prepareForReuse]; - } - return [self.cachedCellSize CGSizeValue]; -} - -- (nullable ConversationViewCell *)measurementCell -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.interaction); - - // For performance reasons, we cache one instance of each kind of - // cell and uses these cells for measurement. - static NSMutableDictionary *measurementCellCache = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - measurementCellCache = [NSMutableDictionary new]; - }); - - NSNumber *cellCacheKey = @(self.interaction.interactionType); - ConversationViewCell *_Nullable measurementCell = measurementCellCache[cellCacheKey]; - if (!measurementCell) { - switch (self.interaction.interactionType) { - case OWSInteractionType_Unknown: - OWSFailDebug(@"Unknown interaction type."); - return nil; - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - measurementCell = [OWSMessageCell new]; - break; - case OWSInteractionType_Error: - case OWSInteractionType_Info: - case OWSInteractionType_Call: - measurementCell = [OWSSystemMessageCell new]; - break; - case OWSInteractionType_TypingIndicator: - measurementCell = [OWSTypingIndicatorCell new]; - break; - } - - OWSAssertDebug(measurementCell); - measurementCellCache[cellCacheKey] = measurementCell; - } - - return measurementCell; -} - -- (CGFloat)vSpacingWithPreviousLayoutItem:(id)previousLayoutItem -{ - OWSAssertDebug(previousLayoutItem); - - if (self.hasCellHeader) { - return OWSMessageHeaderViewDateHeaderVMargin; - } - - // "Bubble Collapse". Adjacent messages with the same author should be close together. - if (self.interaction.interactionType == OWSInteractionType_IncomingMessage - && previousLayoutItem.interaction.interactionType == OWSInteractionType_IncomingMessage) { - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction; - TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousLayoutItem.interaction; - if ([incomingMessage.authorId isEqualToString:previousIncomingMessage.authorId]) { - return 2.f; - } - } else if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage - && previousLayoutItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - return 2.f; - } - - return 12.f; -} - -- (ConversationViewCell *)dequeueCellForCollectionView:(UICollectionView *)collectionView - indexPath:(NSIndexPath *)indexPath -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(collectionView); - OWSAssertDebug(indexPath); - OWSAssertDebug(self.interaction); - - switch (self.interaction.interactionType) { - case OWSInteractionType_Unknown: - OWSFailDebug(@"Unknown interaction type."); - return nil; - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSMessageCell cellReuseIdentifier] - forIndexPath:indexPath]; - case OWSInteractionType_Error: - case OWSInteractionType_Info: - case OWSInteractionType_Call: - return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier] - forIndexPath:indexPath]; - case OWSInteractionType_TypingIndicator: - return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier] - forIndexPath:indexPath]; - } -} - - (nullable TSAttachmentStream *)firstValidAlbumAttachment { OWSAssertDebug(self.mediaAlbumItems.count > 0); @@ -446,7 +333,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) self.audioProgressSeconds = progress; - [self.lastAudioMessageView setProgress:progress / duration]; + [self.lastAudioMessageView setProgress:(int)(progress)]; } - (void)showInvalidAudioFileAlert @@ -458,6 +345,12 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) @"Message for the alert indicating that an audio file is invalid.")]; } +- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag +{ + if (!flag) { return; } + [NSNotificationCenter.defaultCenter postNotificationName:SNAudioDidFinishPlayingNotification object:nil]; +} + #pragma mark - Displayable Text // TODO: Now that we're caching the displayable text on the view items, @@ -659,9 +552,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (self.hasBodyText) { if (self.messageCellType == OWSMessageCellType_Unknown) { -// OWSAssertDebug(message.attachmentIds.count == 0 -// || (message.attachmentIds.count == 1 && -// [message oversizeTextAttachmentWithTransaction:transaction] != nil)); self.messageCellType = OWSMessageCellType_TextOnlyMessage; } OWSAssertDebug(self.displayableBodyText); diff --git a/Session/Conversations/ConversationViewLayout.h b/Session/Conversations/ConversationViewLayout.h deleted file mode 100644 index ac0ed1d4c..000000000 --- a/Session/Conversations/ConversationViewLayout.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; - -@protocol ConversationViewLayoutItem - -- (CGSize)cellSize; - -- (CGFloat)vSpacingWithPreviousLayoutItem:(id)previousLayoutItem; - -@end - -#pragma mark - - -@protocol ConversationViewLayoutDelegate - -- (NSArray> *)layoutItems; - -- (CGFloat)layoutHeaderHeight; - -@end - -#pragma mark - - -// A new lean and efficient layout for conversation view designed to -// handle our edge cases (e.g. full-width unread indicators, etc.). -@interface ConversationViewLayout : UICollectionViewLayout - -@property (nonatomic, weak) id delegate; -@property (nonatomic, readonly) BOOL hasLayout; -@property (nonatomic, readonly) BOOL hasEverHadLayout; -@property (nonatomic, readonly) ConversationStyle *conversationStyle; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewLayout.m b/Session/Conversations/ConversationViewLayout.m deleted file mode 100644 index 6d4f40552..000000000 --- a/Session/Conversations/ConversationViewLayout.m +++ /dev/null @@ -1,168 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewLayout.h" -#import "Session-Swift.h" -#import "UIView+OWS.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface ConversationViewLayout () - -@property (nonatomic) CGFloat lastViewWidth; -@property (nonatomic) CGSize contentSize; - -@property (nonatomic, readonly) NSMutableDictionary *itemAttributesMap; - -// This dirty flag may be redundant with logic in UICollectionViewLayout, -// but it can't hurt and it ensures that we can safely & cheaply call -// prepareLayout from view logic to ensure that we always have a¸valid -// layout without incurring any of the (great) expense of performing an -// unnecessary layout pass. -@property (nonatomic) BOOL hasLayout; -@property (nonatomic) BOOL hasEverHadLayout; - -@end - -#pragma mark - - -@implementation ConversationViewLayout - -- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle -{ - if (self = [super init]) { - _itemAttributesMap = [NSMutableDictionary new]; - _conversationStyle = conversationStyle; - } - - return self; -} - -- (void)setHasLayout:(BOOL)hasLayout -{ - _hasLayout = hasLayout; - - if (hasLayout) { - self.hasEverHadLayout = YES; - } -} - -- (void)invalidateLayout -{ - [super invalidateLayout]; - - [self clearState]; -} - -- (void)invalidateLayoutWithContext:(UICollectionViewLayoutInvalidationContext *)context -{ - [super invalidateLayoutWithContext:context]; - - [self clearState]; -} - -- (void)clearState -{ - self.contentSize = CGSizeZero; - [self.itemAttributesMap removeAllObjects]; - self.hasLayout = NO; - self.lastViewWidth = 0.f; -} - -- (void)prepareLayout -{ - [super prepareLayout]; - - id delegate = self.delegate; - if (!delegate) { - OWSFailDebug(@"Missing delegate"); - [self clearState]; - return; - } - - if (self.collectionView.bounds.size.width <= 0.f || self.collectionView.bounds.size.height <= 0.f) { - OWSFailDebug(@"Collection view has invalid size: %@", NSStringFromCGRect(self.collectionView.bounds)); - [self clearState]; - return; - } - - if (self.hasLayout) { - return; - } - self.hasLayout = YES; - - [self prepareLayoutOfItems]; -} - -- (void)prepareLayoutOfItems -{ - const CGFloat viewWidth = self.conversationStyle.viewWidth; - - NSArray> *layoutItems = self.delegate.layoutItems; - - CGFloat y = self.conversationStyle.contentMarginTop + self.delegate.layoutHeaderHeight; - CGFloat contentBottom = y; - - NSInteger row = 0; - id _Nullable previousLayoutItem = nil; - for (id layoutItem in layoutItems) { - if (previousLayoutItem) { - y += [layoutItem vSpacingWithPreviousLayoutItem:previousLayoutItem]; - } - - CGSize layoutSize = CGSizeCeil([layoutItem cellSize]); - - // Ensure cell fits within view. - OWSAssertDebug(layoutSize.width <= viewWidth); - layoutSize.width = MIN(viewWidth, layoutSize.width); - - // All cells are "full width" and are responsible for aligning their own content. - CGRect itemFrame = CGRectMake(0, y, viewWidth, layoutSize.height); - - NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; - UICollectionViewLayoutAttributes *itemAttributes = - [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath]; - itemAttributes.frame = itemFrame; - self.itemAttributesMap[@(row)] = itemAttributes; - - contentBottom = itemFrame.origin.y + itemFrame.size.height; - y = contentBottom; - row++; - previousLayoutItem = layoutItem; - } - - contentBottom += self.conversationStyle.contentMarginBottom; - self.contentSize = CGSizeMake(viewWidth, contentBottom); - self.lastViewWidth = viewWidth; -} - -- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect -{ - NSMutableArray *result = [NSMutableArray new]; - for (UICollectionViewLayoutAttributes *itemAttributes in self.itemAttributesMap.allValues) { - if (CGRectIntersectsRect(rect, itemAttributes.frame)) { - [result addObject:itemAttributes]; - } - } - return result; -} - -- (nullable UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath -{ - return self.itemAttributesMap[@(indexPath.row)]; -} - -- (CGSize)collectionViewContentSize -{ - return self.contentSize; -} - -- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds -{ - return self.lastViewWidth != newBounds.size.width; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/ConversationViewModel.h b/Session/Conversations/ConversationViewModel.h index 84ef4a6db..d679dbcd9 100644 --- a/Session/Conversations/ConversationViewModel.h +++ b/Session/Conversations/ConversationViewModel.h @@ -87,8 +87,6 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { // to prod the view to reset its scroll state, etc. - (void)conversationViewModelDidReset; -- (ConversationStyle *)conversationStyle; - @end #pragma mark - diff --git a/Session/Conversations/ConversationViewModel.m b/Session/Conversations/ConversationViewModel.m index 3b9557422..1de25d79f 100644 --- a/Session/Conversations/ConversationViewModel.m +++ b/Session/Conversations/ConversationViewModel.m @@ -5,7 +5,6 @@ #import "ConversationViewModel.h" #import "ConversationViewItem.h" #import "DateUtil.h" -#import "OWSMessageBubbleView.h" #import "OWSQuotedReplyModel.h" #import "Session-Swift.h" #import @@ -166,12 +165,12 @@ NS_ASSUME_NONNULL_BEGIN // Always load up to n messages when user arrives. // // The smaller this number is, the faster the conversation can display. -// To test, shrink you accessability font as much as possible, then count how many 1-line system info messages (our +// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our // shortest cells) can fit on screen at a time on an iPhoneX // // PERF: we could do less messages on shorter (older, slower) devices // PERF: we could cache the cell height, since some messages will be much taller. -static const int kYapDatabasePageSize = 18; +static const int kYapDatabasePageSize = 100; // Never show more than n messages in conversation view when user arrives. static const int kConversationInitialMaxRangeSize = 300; @@ -622,13 +621,6 @@ static const int kYapDatabaseRangeMaxLength = 25000; NSMutableSet *diffRemovedItemIds = [diff.removedItemIds mutableCopy]; NSMutableSet *diffUpdatedItemIds = [diff.updatedItemIds mutableCopy]; for (TSOutgoingMessage *unsavedOutgoingMessage in self.unsavedOutgoingMessages) { - // unsavedOutgoingMessages should only exist for a short period (usually 30-50ms) before - // they are saved and moved into the `persistedViewItems` - // Loki: Original code - // ======== -// OWSAssertDebug(unsavedOutgoingMessage.timestamp >= ([NSDate ows_millisecondTimeStamp] - 1 * kSecondInMs)); - // ======== - BOOL isFound = ([diff.addedItemIds containsObject:unsavedOutgoingMessage.uniqueId] || [diff.removedItemIds containsObject:unsavedOutgoingMessage.uniqueId] || [diff.updatedItemIds containsObject:unsavedOutgoingMessage.uniqueId]); @@ -1049,7 +1041,6 @@ static const int kYapDatabaseRangeMaxLength = 25000; NSArray *loadedUniqueIds = [self.messageMapping loadedUniqueIds]; BOOL isGroupThread = self.thread.isGroupThread; - ConversationStyle *conversationStyle = self.delegate.conversationStyle; [self ensureConversationProfileState]; @@ -1062,8 +1053,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; if (!viewItem) { viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction isGroupThread:isGroupThread - transaction:transaction - conversationStyle:conversationStyle]; + transaction:transaction]; } OWSAssertDebug(!viewItemCache[interaction.uniqueId]); viewItemCache[interaction.uniqueId] = viewItem; @@ -1083,13 +1073,11 @@ static const int kYapDatabaseRangeMaxLength = 25000; [TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction]; if (!interaction) { OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId); - // TODO: Add analytics. hasError = YES; continue; } if (!interaction.uniqueId) { OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction); - // TODO: Add analytics. hasError = YES; continue; } @@ -1226,7 +1214,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; id viewItem = viewItems[i]; id _Nullable previousViewItem = (i > 0 ? viewItems[i - 1] : nil); id _Nullable nextViewItem = (i + 1 < viewItems.count ? viewItems[i + 1] : nil); - BOOL shouldShowSenderAvatar = NO; + BOOL shouldShowSenderProfilePicture = NO; BOOL shouldHideFooter = NO; BOOL isFirstInCluster = YES; BOOL isLastInCluster = YES; @@ -1322,9 +1310,8 @@ static const int kYapDatabaseRangeMaxLength = 25000; } if (viewItem.isGroupThread) { - // Show the sender name for incoming group messages unless - // the previous message has the same sender name and - // no "date break" separates us. + // Show the sender name for incoming group messages unless the + // previous message has the same sender and no "date break" separates us. BOOL shouldShowSenderName = YES; NSString *_Nullable previousIncomingSenderId = nil; if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) { @@ -1333,37 +1320,18 @@ static const int kYapDatabaseRangeMaxLength = 25000; previousIncomingSenderId = previousIncomingMessage.authorId; OWSAssertDebug(previousIncomingSenderId.length > 0); - shouldShowSenderName - = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId] - || viewItem.hasCellHeader); + shouldShowSenderName = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId] || viewItem.hasCellHeader); } if (shouldShowSenderName) { senderName = [[NSAttributedString alloc] initWithString:[SSKEnvironment.shared.profileManager profileNameForRecipientWithID:incomingSenderId avoidingWriteTransaction:YES]]; - - if ([self.thread isKindOfClass:[TSGroupThread class]]) { - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - NSData *groupId = groupThread.groupModel.groupId; - NSString *stringGroupId = [[NSString alloc] initWithData:groupId encoding:NSUTF8StringEncoding]; - - if (stringGroupId != nil) { - NSString __block *displayName; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - displayName = [transaction objectForKey:incomingSenderId inCollection:stringGroupId]; - }]; - if (displayName != nil) { - senderName = [[NSAttributedString alloc] initWithString:displayName attributes:[OWSMessageBubbleView senderNamePrimaryAttributes]]; - } - } - } } - // Show the sender avatar for incoming group messages unless - // the next message has the same sender avatar and - // no "date break" separates us. - shouldShowSenderAvatar = YES; - if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) { - shouldShowSenderAvatar = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId]); + // Show the sender profile picture for incoming group messages unless the + // next message has the same sender and no "date break" separates us. + shouldShowSenderProfilePicture = YES; + if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { + shouldShowSenderProfilePicture = (![NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId]); } } } @@ -1374,9 +1342,10 @@ static const int kYapDatabaseRangeMaxLength = 25000; viewItem.isFirstInCluster = isFirstInCluster; viewItem.isLastInCluster = isLastInCluster; - viewItem.shouldShowSenderAvatar = shouldShowSenderAvatar; + viewItem.shouldShowSenderProfilePicture = shouldShowSenderProfilePicture; viewItem.shouldHideFooter = shouldHideFooter; viewItem.senderName = senderName; + viewItem.wasPreviousItemInfoMessage = (previousViewItem.interaction.interactionType == OWSInteractionType_Info); } self.viewState = [[ConversationViewState alloc] initWithViewItems:viewItems]; diff --git a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift new file mode 100644 index 000000000..33dfae446 --- /dev/null +++ b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift @@ -0,0 +1,121 @@ + +final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate { + private let delegate: ExpandingAttachmentsButtonDelegate + private var isExpanded = false { didSet { expandOrCollapse() } } + + // MARK: Constraints + private lazy var gifButtonContainerBottomConstraint = gifButtonContainer.pin(.bottom, to: .bottom, of: self) + private lazy var documentButtonContainerBottomConstraint = documentButtonContainer.pin(.bottom, to: .bottom, of: self) + private lazy var libraryButtonContainerBottomConstraint = libraryButtonContainer.pin(.bottom, to: .bottom, of: self) + private lazy var cameraButtonContainerBottomConstraint = cameraButtonContainer.pin(.bottom, to: .bottom, of: self) + + // MARK: UI Components + lazy var gifButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self, hasOpaqueBackground: true) + lazy var gifButtonContainer = container(for: gifButton) + lazy var documentButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self, hasOpaqueBackground: true) + lazy var documentButtonContainer = container(for: documentButton) + lazy var libraryButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self, hasOpaqueBackground: true) + lazy var libraryButtonContainer = container(for: libraryButton) + lazy var cameraButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), delegate: self, hasOpaqueBackground: true) + lazy var cameraButtonContainer = container(for: cameraButton) + lazy var mainButton = InputViewButton(icon: #imageLiteral(resourceName: "ic_plus_24"), delegate: self) + lazy var mainButtonContainer = container(for: mainButton) + + // MARK: Lifecycle + init(delegate: ExpandingAttachmentsButtonDelegate) { + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(delegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(delegate:) instead.") + } + + private func setUpViewHierarchy() { + backgroundColor = .clear + // GIF button + addSubview(gifButtonContainer) + gifButtonContainer.alpha = 0 + // Document button + addSubview(documentButtonContainer) + documentButtonContainer.alpha = 0 + // Library button + addSubview(libraryButtonContainer) + libraryButtonContainer.alpha = 0 + // Camera button + addSubview(cameraButtonContainer) + cameraButtonContainer.alpha = 0 + // Main button + addSubview(mainButtonContainer) + // Constraints + mainButtonContainer.pin(to: self) + gifButtonContainer.center(.horizontal, in: self) + documentButtonContainer.center(.horizontal, in: self) + libraryButtonContainer.center(.horizontal, in: self) + cameraButtonContainer.center(.horizontal, in: self) + [ gifButtonContainerBottomConstraint, documentButtonContainerBottomConstraint, libraryButtonContainerBottomConstraint, cameraButtonContainerBottomConstraint ].forEach { + $0.isActive = true + } + } + + // MARK: Animation + private func expandOrCollapse() { + if isExpanded { + let expandedButtonSize = InputViewButton.expandedSize + let spacing: CGFloat = 4 + cameraButtonContainerBottomConstraint.constant = -1 * (expandedButtonSize + spacing) + libraryButtonContainerBottomConstraint.constant = -2 * (expandedButtonSize + spacing) + documentButtonContainerBottomConstraint.constant = -3 * (expandedButtonSize + spacing) + gifButtonContainerBottomConstraint.constant = -4 * (expandedButtonSize + spacing) + UIView.animate(withDuration: 0.25) { + [ self.gifButtonContainer, self.documentButtonContainer, self.libraryButtonContainer, self.cameraButtonContainer ].forEach { + $0.alpha = 1 + } + self.layoutIfNeeded() + } + } else { + [ gifButtonContainerBottomConstraint, documentButtonContainerBottomConstraint, libraryButtonContainerBottomConstraint, cameraButtonContainerBottomConstraint ].forEach { + $0.constant = 0 + } + UIView.animate(withDuration: 0.25) { + [ self.gifButtonContainer, self.documentButtonContainer, self.libraryButtonContainer, self.cameraButtonContainer ].forEach { + $0.alpha = 0 + } + self.layoutIfNeeded() + } + } + } + + // MARK: Interaction + func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { + if inputViewButton == gifButton { delegate.handleGIFButtonTapped(); isExpanded = false } + if inputViewButton == documentButton { delegate.handleDocumentButtonTapped(); isExpanded = false } + if inputViewButton == libraryButton { delegate.handleLibraryButtonTapped(); isExpanded = false } + if inputViewButton == cameraButton { delegate.handleCameraButtonTapped(); isExpanded = false } + if inputViewButton == mainButton { isExpanded = !isExpanded } + } + + // MARK: Convenience + private func container(for button: InputViewButton) -> UIView { + let result = UIView() + result.addSubview(button) + result.set(.width, to: InputViewButton.expandedSize) + result.set(.height, to: InputViewButton.expandedSize) + button.center(in: result) + return result + } +} + +// MARK: Delegate +protocol ExpandingAttachmentsButtonDelegate { + + func handleGIFButtonTapped() + func handleDocumentButtonTapped() + func handleLibraryButtonTapped() + func handleCameraButtonTapped() +} diff --git a/Session/Conversations/Input View/InputTextView.swift b/Session/Conversations/Input View/InputTextView.swift new file mode 100644 index 000000000..d907823f0 --- /dev/null +++ b/Session/Conversations/Input View/InputTextView.swift @@ -0,0 +1,80 @@ + +public final class InputTextView : UITextView, UITextViewDelegate { + private let snDelegate: InputTextViewDelegate + private lazy var heightConstraint = self.set(.height, to: minHeight) + + public override var text: String! { didSet { handleTextChanged() } } + + // MARK: UI Components + private lazy var placeholderLabel: UILabel = { + let result = UILabel() + result.text = "Message" + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) + return result + }() + + // MARK: Settings + private let minHeight: CGFloat = 22 + private let maxHeight: CGFloat = 80 + + // MARK: Lifecycle + init(delegate: InputTextViewDelegate) { + snDelegate = delegate + super.init(frame: CGRect.zero, textContainer: nil) + setUpViewHierarchy() + self.delegate = self + } + + public override init(frame: CGRect, textContainer: NSTextContainer?) { + preconditionFailure("Use init(delegate:) instead.") + } + + public required init?(coder: NSCoder) { + preconditionFailure("Use init(delegate:) instead.") + } + + private func setUpViewHierarchy() { + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + backgroundColor = .clear + textColor = Colors.text + font = .systemFont(ofSize: Values.mediumFontSize) + tintColor = Colors.accent + keyboardAppearance = isLightMode ? .light : .dark + heightConstraint.isActive = true + let horizontalInset: CGFloat = 2 + textContainerInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) + addSubview(placeholderLabel) + placeholderLabel.pin(.leading, to: .leading, of: self, withInset: horizontalInset + 3) // Slight visual adjustment + placeholderLabel.pin(.top, to: .top, of: self) + pin(.trailing, to: .trailing, of: placeholderLabel, withInset: horizontalInset) + pin(.bottom, to: .bottom, of: placeholderLabel) + } + + // MARK: Updating + public func textViewDidChange(_ textView: UITextView) { + handleTextChanged() + } + + private func handleTextChanged() { + defer { snDelegate.inputTextViewDidChangeContent(self) } + placeholderLabel.isHidden = !text.isEmpty + let width = frame.width + let height = frame.height + let size = sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + // `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually + self.contentSize = size + let newHeight = size.height.clamp(minHeight, maxHeight) + guard newHeight != height else { return } + heightConstraint.constant = newHeight + snDelegate.inputTextViewDidChangeSize(self) + } +} + +// MARK: Delegate +protocol InputTextViewDelegate { + + func inputTextViewDidChangeSize(_ inputTextView: InputTextView) + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) +} diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift new file mode 100644 index 000000000..4100d7221 --- /dev/null +++ b/Session/Conversations/Input View/InputView.swift @@ -0,0 +1,337 @@ + +final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate { + private let delegate: InputViewDelegate + var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } + var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? + private var voiceMessageRecordingView: VoiceMessageRecordingView? + private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) + + private lazy var linkPreviewView: LinkPreviewView = { + let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset + return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self) + }() + + var text: String { + get { inputTextView.text } + set { inputTextView.text = newValue } + } + + override var intrinsicContentSize: CGSize { CGSize.zero } + var lastSearchedText: String? { nil } + + // MARK: UI Components + private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate) + + private lazy var voiceMessageButton = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) + + private lazy var sendButton: InputViewButton = { + let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) + result.isHidden = true + return result + }() + private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) + + private lazy var mentionsView: MentionSelectionView = { + let result = MentionSelectionView() + result.delegate = self + return result + }() + + private lazy var mentionsViewContainer: UIView = { + let result = UIView() + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + result.addSubview(backgroundView) + backgroundView.pin(to: result) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + result.addSubview(blurView) + blurView.pin(to: result) + result.alpha = 0 + return result + }() + + private lazy var inputTextView = InputTextView(delegate: self) + + private lazy var additionalContentContainer: UIView = { + let result = UIView() + result.heightAnchor.constraint(greaterThanOrEqualToConstant: 4).isActive = true + return result + }() + + // MARK: Settings + private static let linkPreviewViewInset: CGFloat = 6 + + // MARK: Lifecycle + init(delegate: InputViewDelegate) { + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(delegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(delegate:) instead.") + } + + private func setUpViewHierarchy() { + autoresizingMask = .flexibleHeight + // Background & blur + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + addSubview(backgroundView) + backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + addSubview(blurView) + blurView.pin(to: self) + // Separator + let separator = UIView() + separator.backgroundColor = Colors.text.withAlphaComponent(0.2) + separator.set(.height, to: 1 / UIScreen.main.scale) + addSubview(separator) + separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) + // Bottom stack view + let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ]) + bottomStackView.axis = .horizontal + bottomStackView.spacing = Values.smallSpacing + bottomStackView.alignment = .center + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ]) + mainStackView.axis = .vertical + mainStackView.isLayoutMarginsRelativeArrangement = true + let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 + mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing - adjustment) + addSubview(mainStackView) + mainStackView.pin(.top, to: .bottom, of: separator) + mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) + mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) + // Mentions + insertSubview(mentionsViewContainer, belowSubview: mainStackView) + mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) + mentionsViewContainer.pin(.bottom, to: .top, of: self) + mentionsViewContainer.addSubview(mentionsView) + mentionsView.pin(to: mentionsViewContainer) + mentionsViewHeightConstraint.isActive = true + // Voice message button + addSubview(voiceMessageButtonContainer) + voiceMessageButtonContainer.center(in: sendButton) + } + + // MARK: Updating + func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { + invalidateIntrinsicContentSize() + } + + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + let hasText = !text.isEmpty + sendButton.isHidden = !hasText + voiceMessageButtonContainer.isHidden = hasText + autoGenerateLinkPreviewIfPossible() + delegate.inputTextViewDidChangeContent(inputTextView) + } + + private func handleQuoteDraftChanged() { + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + linkPreviewInfo = nil + guard let quoteDraftInfo = quoteDraftInfo else { return } + let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming + let hInset: CGFloat = 6 + let maxWidth = additionalContentContainer.bounds.width + let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self) + additionalContentContainer.addSubview(quoteView) + quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset) + quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) + quoteView.pin(.right, to: .right, of: additionalContentContainer, withInset: -hInset) + quoteView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -6) + } + + private func autoGenerateLinkPreviewIfPossible() { + // Suggest that the user enable link previews if they haven't already and we haven't + // told them about link previews yet + let text = inputTextView.text! + let userDefaults = UserDefaults.standard + if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled + && !userDefaults[.hasSeenLinkPreviewSuggestion] { + delegate.showLinkPreviewSuggestionModal() + userDefaults[.hasSeenLinkPreviewSuggestion] = true + return + } + // Check that link previews are enabled + guard SSKPreferences.areLinkPreviewsEnabled else { return } + // Proceed + autoGenerateLinkPreview() + } + + func autoGenerateLinkPreview() { + // Check that a valid URL is present + guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else { + return + } + // Guard against obsolete updates + guard linkPreviewURL != self.linkPreviewInfo?.url else { return } + // Clear content container + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + quoteDraftInfo = nil + // Set the state to loading + linkPreviewInfo = (url: linkPreviewURL, draft: nil) + linkPreviewView.linkPreviewState = LinkPreviewLoading() + // Add the link preview view + additionalContentContainer.addSubview(linkPreviewView) + linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) + linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) + linkPreviewView.pin(.right, to: .right, of: additionalContentContainer) + linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) + // Build the link preview + OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in + guard let self = self else { return } + guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + self.linkPreviewInfo = (url: linkPreviewURL, draft: draft) + self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft) + }.catch { _ in + guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + self.linkPreviewInfo = nil + self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + }.retainUntilComplete() + } + + // MARK: Interaction + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, + attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] + let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) } + if let buttonContainer = buttonContainer { + return buttonContainer + } else { + return super.hitTest(point, with: event) + } + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer, + attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] + let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) } + if isPointInsideAttachmentsButton { + return true + } else if mentionsViewContainer.frame.contains(point) { + return true + } else { + return super.point(inside: point, with: event) + } + } + + func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { + if inputViewButton == sendButton { delegate.handleSendButtonTapped() } + } + + func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { + guard inputViewButton == voiceMessageButton else { return } + delegate.startVoiceMessageRecording() + showVoiceMessageUI() + } + + func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { + guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return } + let location = touch.location(in: voiceMessageRecordingView) + voiceMessageRecordingView.handleLongPressMoved(to: location) + } + + func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { + guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return } + let location = touch.location(in: voiceMessageRecordingView) + voiceMessageRecordingView.handleLongPressEnded(at: location) + } + + func handleQuoteViewCancelButtonTapped() { + delegate.handleQuoteViewCancelButtonTapped() + } + + override func resignFirstResponder() -> Bool { + inputTextView.resignFirstResponder() + } + + func handleLongPress() { + // Not relevant in this case + } + + func handleLinkPreviewCanceled() { + linkPreviewInfo = nil + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } + + @objc private func showVoiceMessageUI() { + voiceMessageRecordingView?.removeFromSuperview() + let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self) + let voiceMessageRecordingView = VoiceMessageRecordingView(voiceMessageButtonFrame: voiceMessageButtonFrame, delegate: delegate) + voiceMessageRecordingView.alpha = 0 + addSubview(voiceMessageRecordingView) + voiceMessageRecordingView.pin(to: self) + self.voiceMessageRecordingView = voiceMessageRecordingView + voiceMessageRecordingView.animate() + let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] + UIView.animate(withDuration: 0.25) { + allOtherViews.forEach { $0.alpha = 0 } + } + } + + func hideVoiceMessageUI() { + let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] + UIView.animate(withDuration: 0.25, animations: { + allOtherViews.forEach { $0.alpha = 1 } + self.voiceMessageRecordingView?.alpha = 0 + }, completion: { _ in + self.voiceMessageRecordingView?.removeFromSuperview() + self.voiceMessageRecordingView = nil + }) + } + + func hideMentionsUI() { + UIView.animate(withDuration: 0.25, animations: { + self.mentionsViewContainer.alpha = 0 + }, completion: { _ in + self.mentionsViewHeightConstraint.constant = 0 + self.mentionsView.tableView.contentOffset = CGPoint.zero + }) + } + + func showMentionsUI(for candidates: [Mention], in thread: TSThread) { + if let openGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!) { + mentionsView.openGroupServer = openGroup.server + mentionsView.openGroupChannel = openGroup.channel + } + mentionsView.candidates = candidates + let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing + mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight + layoutIfNeeded() + UIView.animate(withDuration: 0.25) { + self.mentionsViewContainer.alpha = 1 + } + } + + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { + delegate.handleMentionSelected(mention, from: view) + } + + // MARK: Convenience + private func container(for button: InputViewButton) -> UIView { + let result = UIView() + result.addSubview(button) + result.set(.width, to: InputViewButton.expandedSize) + result.set(.height, to: InputViewButton.expandedSize) + button.center(in: result) + return result + } +} + +// MARK: Delegate +protocol InputViewDelegate : ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { + + func showLinkPreviewSuggestionModal() + func handleSendButtonTapped() + func handleQuoteViewCancelButtonTapped() + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) +} diff --git a/Session/Conversations/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift new file mode 100644 index 000000000..31a08dff7 --- /dev/null +++ b/Session/Conversations/Input View/InputViewButton.swift @@ -0,0 +1,145 @@ + +final class InputViewButton : UIView { + private let icon: UIImage + private let isSendButton: Bool + private let delegate: InputViewButtonDelegate + private let hasOpaqueBackground: Bool + private lazy var widthConstraint = set(.width, to: InputViewButton.size) + private lazy var heightConstraint = set(.height, to: InputViewButton.size) + private var longPressTimer: Timer? + private var isLongPress = false + + // MARK: UI Components + private lazy var backgroundView = UIView() + + // MARK: Settings + static let size = CGFloat(40) + static let expandedSize = CGFloat(48) + static let iconSize: CGFloat = 20 + + // MARK: Lifecycle + init(icon: UIImage, isSendButton: Bool = false, delegate: InputViewButtonDelegate, hasOpaqueBackground: Bool = false) { + self.icon = icon + self.isSendButton = isSendButton + self.delegate = delegate + self.hasOpaqueBackground = hasOpaqueBackground + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(icon:delegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(icon:delegate:) instead.") + } + + private func setUpViewHierarchy() { + backgroundColor = .clear + if hasOpaqueBackground { + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + addSubview(backgroundView) + backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + addSubview(blurView) + blurView.pin(to: self) + } + backgroundView.backgroundColor = isSendButton ? Colors.accent : Colors.text.withAlphaComponent(0.05) + addSubview(backgroundView) + backgroundView.pin(to: self) + layer.cornerRadius = InputViewButton.size / 2 + layer.masksToBounds = true + isUserInteractionEnabled = true + widthConstraint.isActive = true + heightConstraint.isActive = true + let tint = isSendButton ? UIColor.black : Colors.text + let iconImageView = UIImageView(image: icon.withTint(tint)) + iconImageView.contentMode = .scaleAspectFit + let iconSize = InputViewButton.iconSize + iconImageView.set(.width, to: iconSize) + iconImageView.set(.height, to: iconSize) + addSubview(iconImageView) + iconImageView.center(in: self) + } + + // MARK: Animation + private func animate(to size: CGFloat, glowColor: UIColor, backgroundColor: UIColor) { + let frame = CGRect(center: center, size: CGSize(width: size, height: size)) + widthConstraint.constant = size + heightConstraint.constant = size + UIView.animate(withDuration: 0.25) { + self.layoutIfNeeded() + self.frame = frame + self.layer.cornerRadius = size / 2 + let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: true, radius: isLightMode ? 4 : 6) + self.setCircularGlow(with: glowConfiguration) + self.backgroundView.backgroundColor = backgroundColor + } + } + + private func expand() { + animate(to: InputViewButton.expandedSize, glowColor: Colors.expandedButtonGlowColor, backgroundColor: Colors.accent) + } + + private func collapse() { + let backgroundColor = isSendButton ? Colors.accent : Colors.text.withAlphaComponent(0.05) + animate(to: InputViewButton.size, glowColor: .clear, backgroundColor: backgroundColor) + } + + // MARK: Interaction + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + expand() + invalidateLongPressIfNeeded() + longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in + guard let self = self else { return } + self.isLongPress = true + self.delegate.handleInputViewButtonLongPressBegan(self) + }) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + if isLongPress { + delegate.handleInputViewButtonLongPressMoved(self, with: touches.first!) + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + collapse() + if !isLongPress { + delegate.handleInputViewButtonTapped(self) + } else { + delegate.handleInputViewButtonLongPressEnded(self, with: touches.first!) + } + invalidateLongPressIfNeeded() + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + collapse() + invalidateLongPressIfNeeded() + } + + private func invalidateLongPressIfNeeded() { + longPressTimer?.invalidate() + isLongPress = false + } +} + +// MARK: Delegate +protocol InputViewButtonDelegate { + + func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) + func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) + func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) + func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) +} + +extension InputViewButtonDelegate { + + func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { } + func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { } + func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { } +} diff --git a/Session/Conversations/Views & Cells/MentionCandidateSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift similarity index 58% rename from Session/Conversations/Views & Cells/MentionCandidateSelectionView.swift rename to Session/Conversations/Input View/MentionSelectionView.swift index f62d50d8b..103781879 100644 --- a/Session/Conversations/Views & Cells/MentionCandidateSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -1,19 +1,17 @@ -@objc(LKMentionCandidateSelectionView) -final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITableViewDelegate { - @objc var mentionCandidates: [Mention] = [] { didSet { tableView.reloadData() } } - @objc var publicChatServer: String? - var publicChatChannel: UInt64? - @objc var delegate: MentionCandidateSelectionViewDelegate? - - // MARK: Convenience - @objc(setPublicChatChannel:) - func setPublicChatChannel(to publicChatChannel: UInt64) { - self.publicChatChannel = publicChatChannel != 0 ? publicChatChannel : nil +final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate { + var candidates: [Mention] = [] { + didSet { + tableView.isScrollEnabled = (candidates.count > 4) + tableView.reloadData() + } } - + var openGroupServer: String? + var openGroupChannel: UInt64? + var delegate: MentionSelectionViewDelegate? + // MARK: Components - @objc lazy var tableView: UITableView = { // TODO: Make this private + lazy var tableView: UITableView = { // TODO: Make this private let result = UITableView() result.dataSource = self result.delegate = self @@ -23,21 +21,23 @@ final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITab result.showsVerticalScrollIndicator = false return result }() - + // MARK: Initialization override init(frame: CGRect) { super.init(frame: frame) setUpViewHierarchy() } - + required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() } - + private func setUpViewHierarchy() { + // Table view addSubview(tableView) tableView.pin(to: self) + // Top separator let topSeparator = UIView() topSeparator.backgroundColor = Colors.separator topSeparator.set(.height, to: Values.separatorThickness) @@ -45,6 +45,7 @@ final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITab topSeparator.pin(.leading, to: .leading, of: self) topSeparator.pin(.top, to: .top, of: self) topSeparator.pin(.trailing, to: .trailing, of: self) + // Bottom separator let bottomSeparator = UIView() bottomSeparator.backgroundColor = Colors.separator bottomSeparator.set(.height, to: Values.separatorThickness) @@ -53,46 +54,43 @@ final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITab bottomSeparator.pin(.trailing, to: .trailing, of: self) bottomSeparator.pin(.bottom, to: .bottom, of: self) } - + // MARK: Data func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return mentionCandidates.count + return candidates.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell - let mentionCandidate = mentionCandidates[indexPath.row] + let mentionCandidate = candidates[indexPath.row] cell.mentionCandidate = mentionCandidate - cell.publicChatServer = publicChatServer - cell.publicChatChannel = publicChatChannel - cell.separator.isHidden = (indexPath.row == (mentionCandidates.count - 1)) + cell.openGroupServer = openGroupServer + cell.openGroupChannel = openGroupChannel + cell.separator.isHidden = (indexPath.row == (candidates.count - 1)) return cell } - + // MARK: Interaction func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let mentionCandidate = mentionCandidates[indexPath.row] - delegate?.handleMentionCandidateSelected(mentionCandidate, from: self) + let mentionCandidate = candidates[indexPath.row] + delegate?.handleMentionSelected(mentionCandidate, from: self) } } // MARK: - Cell -private extension MentionCandidateSelectionView { - +private extension MentionSelectionView { + final class Cell : UITableViewCell { var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } } - var publicChatServer: String? - var publicChatChannel: UInt64? - + var openGroupServer: String? + var openGroupChannel: UInt64? + // MARK: Components private lazy var profilePictureView = ProfilePictureView() - - private lazy var moderatorIconImageView: UIImageView = { - let result = UIImageView(image: #imageLiteral(resourceName: "Crown")) - return result - }() - + + private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) + private lazy var displayNameLabel: UILabel = { let result = UILabel() result.textColor = Colors.text @@ -100,68 +98,68 @@ private extension MentionCandidateSelectionView { result.lineBreakMode = .byTruncatingTail return result }() - + lazy var separator: UIView = { let result = UIView() result.backgroundColor = Colors.separator result.set(.height, to: Values.separatorThickness) return result }() - + // MARK: Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setUpViewHierarchy() } - + required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() } - + private func setUpViewHierarchy() { - // Set the cell background color - backgroundColor = Colors.cellBackground - // Set up the highlight color + // Cell background color + backgroundColor = .clear + // Highlight color let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Colors.cellBackground // Intentionally not Colors.cellSelected + selectedBackgroundView.backgroundColor = .clear self.selectedBackgroundView = selectedBackgroundView - // Set up the profile picture image view - let profilePictureViewSize = Values.verySmallProfilePictureSize + // Profile picture image view + let profilePictureViewSize = Values.smallProfilePictureSize profilePictureView.set(.width, to: profilePictureViewSize) profilePictureView.set(.height, to: profilePictureViewSize) profilePictureView.size = profilePictureViewSize - // Set up the main stack view - let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.spacing = Values.mediumSpacing - stackView.set(.height, to: profilePictureViewSize) - contentView.addSubview(stackView) - stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) - stackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing) - contentView.pin(.trailing, to: .trailing, of: stackView, withInset: Values.mediumSpacing) - contentView.pin(.bottom, to: .bottom, of: stackView, withInset: Values.smallSpacing) - stackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) - // Set up the moderator icon image view + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ]) + mainStackView.axis = .horizontal + mainStackView.alignment = .center + mainStackView.spacing = Values.mediumSpacing + mainStackView.set(.height, to: profilePictureViewSize) + contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) + mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing) + contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing) + contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing) + mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) + // Moderator icon image view moderatorIconImageView.set(.width, to: 20) moderatorIconImageView.set(.height, to: 20) contentView.addSubview(moderatorIconImageView) - moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView) - moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 3.5) - // Set up the separator + moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) + moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Separator addSubview(separator) separator.pin(.leading, to: .leading, of: self) separator.pin(.trailing, to: .trailing, of: self) separator.pin(.bottom, to: .bottom, of: self) } - + // MARK: Updating private func update() { displayNameLabel.text = mentionCandidate.displayName profilePictureView.hexEncodedPublicKey = mentionCandidate.publicKey profilePictureView.update() - if let server = publicChatServer, let channel = publicChatChannel { + if let server = openGroupServer, let channel = openGroupChannel { let isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, for: channel, on: server) moderatorIconImageView.isHidden = !isUserModerator } else { @@ -173,8 +171,7 @@ private extension MentionCandidateSelectionView { // MARK: - Delegate -@objc(LKMentionCandidateSelectionViewDelegate) -protocol MentionCandidateSelectionViewDelegate { +protocol MentionSelectionViewDelegate { - func handleMentionCandidateSelected(_ mentionCandidate: Mention, from mentionCandidateSelectionView: MentionCandidateSelectionView) + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) } diff --git a/Session/Conversations/Input View/VoiceMessageRecordingView.swift b/Session/Conversations/Input View/VoiceMessageRecordingView.swift new file mode 100644 index 000000000..bd558394f --- /dev/null +++ b/Session/Conversations/Input View/VoiceMessageRecordingView.swift @@ -0,0 +1,405 @@ + +final class VoiceMessageRecordingView : UIView { + private let voiceMessageButtonFrame: CGRect + private let delegate: VoiceMessageRecordingViewDelegate + private lazy var slideToCancelStackViewRightConstraint = slideToCancelStackView.pin(.right, to: .right, of: self) + private lazy var slideToCancelLabelCenterHorizontalConstraint = slideToCancelLabel.center(.horizontal, in: self) + private lazy var pulseViewWidthConstraint = pulseView.set(.width, to: VoiceMessageRecordingView.circleSize) + private lazy var pulseViewHeightConstraint = pulseView.set(.height, to: VoiceMessageRecordingView.circleSize) + private lazy var lockViewBottomConstraint = lockView.pin(.bottom, to: .top, of: self, withInset: Values.mediumSpacing) + private let recordingStartDate = Date() + private var recordingTimer: Timer? + + // MARK: UI Components + private lazy var iconImageView: UIImageView = { + let result = UIImageView() + result.image = UIImage(named: "Microphone")!.withTint(.white) + result.contentMode = .scaleAspectFit + let size = VoiceMessageRecordingView.iconSize + result.set(.width, to: size) + result.set(.height, to: size) + return result + }() + + private lazy var circleView: UIView = { + let result = UIView() + result.backgroundColor = Colors.destructive + let size = VoiceMessageRecordingView.circleSize + result.set(.width, to: size) + result.set(.height, to: size) + result.layer.cornerRadius = size / 2 + result.layer.masksToBounds = true + return result + }() + + private lazy var pulseView: UIView = { + let result = UIView() + result.backgroundColor = Colors.destructive + result.layer.cornerRadius = VoiceMessageRecordingView.circleSize / 2 + result.layer.masksToBounds = true + result.alpha = 0.5 + return result + }() + + private lazy var slideToCancelStackView: UIStackView = { + let result = UIStackView() + result.axis = .horizontal + result.spacing = Values.smallSpacing + result.alignment = .center + return result + }() + + private lazy var chevronImageView: UIImageView = { + let chevronSize = VoiceMessageRecordingView.chevronSize + let chevronColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.mediumOpacity) + let result = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(chevronColor)) + result.contentMode = .scaleAspectFit + result.set(.width, to: chevronSize) + result.set(.height, to: chevronSize) + return result + }() + + private lazy var slideToCancelLabel: UILabel = { + let result = UILabel() + result.text = "Slide to cancel" + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) + return result + }() + + private lazy var cancelButton: UIButton = { + let result = UIButton() + result.setTitle("Cancel", for: UIControl.State.normal) + result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.setTitleColor(Colors.text, for: UIControl.State.normal) + result.addTarget(self, action: #selector(handleCancelButtonTapped), for: UIControl.Event.touchUpInside) + result.alpha = 0 + return result + }() + + private lazy var durationStackView: UIStackView = { + let result = UIStackView() + result.axis = .horizontal + result.spacing = Values.smallSpacing + result.alignment = .center + return result + }() + + private lazy var dotView: UIView = { + let result = UIView() + result.backgroundColor = Colors.destructive + let dotSize = VoiceMessageRecordingView.dotSize + result.set(.width, to: dotSize) + result.set(.height, to: dotSize) + result.layer.cornerRadius = dotSize / 2 + result.layer.masksToBounds = true + return result + }() + + private lazy var durationLabel: UILabel = { + let result = UILabel() + result.textColor = Colors.text + result.font = .systemFont(ofSize: Values.smallFontSize) + result.text = "0:00" + return result + }() + + private lazy var lockView = LockView() + + // MARK: Settings + private static let circleSize: CGFloat = 96 + private static let pulseSize: CGFloat = 24 + private static let iconSize: CGFloat = 28 + private static let chevronSize: CGFloat = 16 + private static let dotSize: CGFloat = 16 + private static let lockViewHitMargin: CGFloat = 40 + + // MARK: Lifecycle + init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate) { + self.voiceMessageButtonFrame = voiceMessageButtonFrame + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.updateDurationLabel() + } + } + + override init(frame: CGRect) { + preconditionFailure("Use init(voiceMessageButtonFrame:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(voiceMessageButtonFrame:) instead.") + } + + deinit { + recordingTimer?.invalidate() + } + + private func setUpViewHierarchy() { + // Icon + let iconSize = VoiceMessageRecordingView.iconSize + addSubview(iconImageView) + let voiceMessageButtonCenter = voiceMessageButtonFrame.center + iconImageView.pin(.left, to: .left, of: self, withInset: voiceMessageButtonCenter.x - iconSize / 2) + iconImageView.pin(.top, to: .top, of: self, withInset: voiceMessageButtonCenter.y - iconSize / 2) + // Circle + insertSubview(circleView, at: 0) + circleView.center(in: iconImageView) + // Pulse + insertSubview(pulseView, at: 0) + pulseView.center(in: circleView) + // Slide to cancel stack view + slideToCancelStackView.addArrangedSubview(chevronImageView) + slideToCancelStackView.addArrangedSubview(slideToCancelLabel) + addSubview(slideToCancelStackView) + slideToCancelStackViewRightConstraint.isActive = true + slideToCancelStackView.center(.vertical, in: iconImageView) + // Cancel button + addSubview(cancelButton) + cancelButton.center(.horizontal, in: self) + cancelButton.center(.vertical, in: iconImageView) + // Duration stack view + durationStackView.addArrangedSubview(dotView) + durationStackView.addArrangedSubview(durationLabel) + addSubview(durationStackView) + durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing) + durationStackView.center(.vertical, in: iconImageView) + // Lock view + addSubview(lockView) + lockView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor, constant: 2).isActive = true + lockViewBottomConstraint.isActive = true + } + + // MARK: Updating + @objc private func updateDurationLabel() { + let interval = Date().timeIntervalSince(recordingStartDate) + durationLabel.text = OWSFormat.formatDurationSeconds(Int(interval)) + } + + // MARK: Animation + func animate() { + layoutIfNeeded() + slideToCancelStackViewRightConstraint.isActive = false + slideToCancelLabelCenterHorizontalConstraint.isActive = true + lockViewBottomConstraint.constant = -Values.mediumSpacing + UIView.animate(withDuration: 0.25, animations: { [weak self] in + guard let self = self else { return } + self.alpha = 1 + self.layoutIfNeeded() + }, completion: { [weak self] _ in + guard let self = self else { return } + self.fadeOutDotView() + self.pulse() + }) + } + + private func fadeOutDotView() { + UIView.animate(withDuration: 0.5, animations: { [weak self] in + self?.dotView.alpha = 0 + }, completion: { [weak self] _ in + self?.fadeInDotView() + }) + } + + private func fadeInDotView() { + UIView.animate(withDuration: 0.5, animations: { [weak self] in + self?.dotView.alpha = 1 + }, completion: { [weak self] _ in + self?.fadeOutDotView() + }) + } + + private func pulse() { + let collapsedSize = VoiceMessageRecordingView.circleSize + let collapsedFrame = CGRect(center: pulseView.center, size: CGSize(width: collapsedSize, height: collapsedSize)) + let expandedSize = VoiceMessageRecordingView.circleSize + VoiceMessageRecordingView.pulseSize + let expandedFrame = CGRect(center: pulseView.center, size: CGSize(width: expandedSize, height: expandedSize)) + pulseViewWidthConstraint.constant = expandedSize + pulseViewHeightConstraint.constant = expandedSize + UIView.animate(withDuration: 1, animations: { [weak self] in + guard let self = self else { return } + self.layoutIfNeeded() + self.pulseView.frame = expandedFrame + self.pulseView.layer.cornerRadius = expandedSize / 2 + self.pulseView.alpha = 0 + }, completion: { [weak self] _ in + guard let self = self else { return } + self.pulseViewWidthConstraint.constant = collapsedSize + self.pulseViewHeightConstraint.constant = collapsedSize + self.pulseView.frame = collapsedFrame + self.pulseView.layer.cornerRadius = collapsedSize / 2 + self.pulseView.alpha = 0.5 + self.pulse() + }) + } + + // MARK: Interaction + func handleLongPressMoved(to location: CGPoint) { + if location.x < bounds.center.x { + let translationX = location.x - bounds.center.x + let sign: CGFloat = -1 + let chevronDamping: CGFloat = 4 + let labelDamping: CGFloat = 3 + let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign + let labelX = (labelDamping * (sqrt(abs(translationX)) / sqrt(labelDamping))) * sign + chevronImageView.transform = CGAffineTransform(translationX: chevronX, y: 0) + slideToCancelLabel.transform = CGAffineTransform(translationX: labelX, y: 0) + } else { + chevronImageView.transform = .identity + slideToCancelLabel.transform = .identity + } + if isValidLockViewLocation(location) { + if !lockView.isExpanded { + UIView.animate(withDuration: 0.25) { + self.lockViewBottomConstraint.constant = -Values.mediumSpacing + LockView.expansionMargin + } + } + lockView.expandIfNeeded() + } else { + if lockView.isExpanded { + UIView.animate(withDuration: 0.25) { + self.lockViewBottomConstraint.constant = -Values.mediumSpacing + } + } + lockView.collapseIfNeeded() + } + } + + func handleLongPressEnded(at location: CGPoint) { + if pulseView.frame.contains(location) { + delegate.endVoiceMessageRecording() + } else if isValidLockViewLocation(location) { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap)) + circleView.addGestureRecognizer(tapGestureRecognizer) + UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: { + self.lockView.alpha = 0 + self.iconImageView.image = UIImage(named: "ArrowUp")!.withTint(.white) + self.slideToCancelStackView.alpha = 0 + self.cancelButton.alpha = 1 + }, completion: { _ in + // Do nothing + }) + } else { + delegate.cancelVoiceMessageRecording() + } + } + + @objc private func handleCircleViewTap() { + delegate.endVoiceMessageRecording() + } + + @objc private func handleCancelButtonTapped() { + delegate.cancelVoiceMessageRecording() + } + + // MARK: Convenience + private func isValidLockViewLocation(_ location: CGPoint) -> Bool { + let lockViewHitMargin = VoiceMessageRecordingView.lockViewHitMargin + return location.y < 0 && location.x > (lockView.frame.minX - lockViewHitMargin) && location.x < (lockView.frame.maxX + lockViewHitMargin) + } +} + +// MARK: Lock View +extension VoiceMessageRecordingView { + + fileprivate final class LockView : UIView { + private lazy var widthConstraint = set(.width, to: LockView.width) + private(set) var isExpanded = false + + private lazy var stackView: UIStackView = { + let result = UIStackView() + result.axis = .vertical + result.spacing = Values.smallSpacing + result.alignment = .center + result.isLayoutMarginsRelativeArrangement = true + result.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0) + return result + }() + + private static let width: CGFloat = 44 + static let expansionMargin: CGFloat = 3 + private static let lockIconSize: CGFloat = 20 + private static let chevronIconSize: CGFloat = 20 + + override init(frame: CGRect) { + super.init(frame: frame) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + let iconTint: UIColor = isLightMode ? .black : .white + // Background & blur + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + addSubview(backgroundView) + backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + addSubview(blurView) + blurView.pin(to: self) + // Size & shape + widthConstraint.isActive = true + layer.cornerRadius = LockView.width / 2 + layer.masksToBounds = true + // Border + layer.borderWidth = 1 + let borderColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.veryLowOpacity) + layer.borderColor = borderColor.cgColor + // Lock icon + let lockIconImageView = UIImageView(image: UIImage(named: "ic_lock_outline")!.withTint(iconTint)) + let lockIconSize = LockView.lockIconSize + lockIconImageView.set(.width, to: lockIconSize) + lockIconImageView.set(.height, to: lockIconSize) + stackView.addArrangedSubview(lockIconImageView) + // Chevron icon + let chevronIconImageView = UIImageView(image: UIImage(named: "ic_chevron_up")!.withTint(iconTint)) + let chevronIconSize = LockView.chevronIconSize + chevronIconImageView.set(.width, to: chevronIconSize) + chevronIconImageView.set(.height, to: chevronIconSize) + stackView.addArrangedSubview(chevronIconImageView) + // Stack view + addSubview(stackView) + stackView.pin(to: self) + } + + func expandIfNeeded() { + guard !isExpanded else { return } + isExpanded = true + let expansionMargin = LockView.expansionMargin + let newWidth = LockView.width + 2 * expansionMargin + widthConstraint.constant = newWidth + UIView.animate(withDuration: 0.25) { + self.layer.cornerRadius = newWidth / 2 + self.stackView.layoutMargins = UIEdgeInsets(top: 12 + expansionMargin, leading: 0, bottom: 8 + expansionMargin, trailing: 0) + self.layoutIfNeeded() + } + } + + func collapseIfNeeded() { + guard isExpanded else { return } + isExpanded = false + let newWidth = LockView.width + widthConstraint.constant = newWidth + UIView.animate(withDuration: 0.25) { + self.layer.cornerRadius = newWidth / 2 + self.stackView.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0) + self.layoutIfNeeded() + } + } + } +} + +// MARK: Delegate +protocol VoiceMessageRecordingViewDelegate { + + func startVoiceMessageRecording() + func endVoiceMessageRecording() + func cancelVoiceMessageRecording() +} diff --git a/Session/Conversations/LongTextViewController.swift b/Session/Conversations/LongTextViewController.swift index 278b0e85b..ed8e043f8 100644 --- a/Session/Conversations/LongTextViewController.swift +++ b/Session/Conversations/LongTextViewController.swift @@ -4,7 +4,6 @@ import Foundation import SignalUtilitiesKit -import SignalUtilitiesKit @objc public protocol LongTextViewDelegate { @@ -118,7 +117,7 @@ public class LongTextViewController: OWSViewController { let messageTextView = OWSTextView() self.messageTextView = messageTextView - messageTextView.font = .systemFont(ofSize: Values.mediumFontSize) + messageTextView.font = .systemFont(ofSize: Values.smallFontSize) messageTextView.backgroundColor = .clear messageTextView.isOpaque = true messageTextView.isEditable = false diff --git a/Session/Conversations/MenuActionsViewController.swift b/Session/Conversations/MenuActionsViewController.swift deleted file mode 100644 index 15ac746d3..000000000 --- a/Session/Conversations/MenuActionsViewController.swift +++ /dev/null @@ -1,467 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public class MenuAction: NSObject { - let block: (MenuAction) -> Void - let image: UIImage - let title: String - let subtitle: String? - - public init(image: UIImage, title: String, subtitle: String?, block: @escaping (MenuAction) -> Void) { - self.image = image - self.title = title - self.subtitle = subtitle - self.block = block - } -} - -@objc -protocol MenuActionsViewControllerDelegate: class { - func menuActionsWillPresent(_ menuActionsViewController: MenuActionsViewController) - func menuActionsIsPresenting(_ menuActionsViewController: MenuActionsViewController) - func menuActionsDidPresent(_ menuActionsViewController: MenuActionsViewController) - - func menuActionsIsDismissing(_ menuActionsViewController: MenuActionsViewController) - func menuActionsDidDismiss(_ menuActionsViewController: MenuActionsViewController) -} - -@objc -class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { - - @objc - weak var delegate: MenuActionsViewControllerDelegate? - - @objc - public let focusedInteraction: TSInteraction - - private let focusedView: UIView - private let actionSheetView: MenuActionSheetView - - deinit { - Logger.verbose("") - } - - @objc - required init(focusedInteraction: TSInteraction, focusedView: UIView, actions: [MenuAction]) { - self.focusedView = focusedView - self.focusedInteraction = focusedInteraction - - self.actionSheetView = MenuActionSheetView(actions: actions) - super.init(nibName: nil, bundle: nil) - - actionSheetView.delegate = self - } - - required init?(coder aDecoder: NSCoder) { - notImplemented() - } - - // MARK: View LifeCycle - - var actionSheetViewVerticalConstraint: NSLayoutConstraint? - - override func loadView() { - self.view = UIView() - - view.addSubview(actionSheetView) - - actionSheetView.autoPinWidthToSuperview() - actionSheetView.setContentHuggingVerticalHigh() - actionSheetView.setCompressionResistanceHigh() - self.actionSheetViewVerticalConstraint = actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view) - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBackground)) - self.view.addGestureRecognizer(tapGesture) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(true) - - self.animatePresentation() - } - - override func viewDidDisappear(_ animated: Bool) { - Logger.debug("") - super.viewDidDisappear(animated) - - // When the user has manually dismissed the menu, we do a nice animation - // but if the view otherwise disappears (e.g. due to resigning active), - // we still want to give the delegate the information it needs to restore it's UI. - delegate?.menuActionsDidDismiss(self) - } - - // MARK: Orientation - - override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return DefaultUIInterfaceOrientationMask() - } - - // MARK: Present / Dismiss animations - - var snapshotView: UIView? - - private func addSnapshotFocusedView() -> UIView? { - guard let snapshotView = self.focusedView.snapshotView(afterScreenUpdates: false) else { - owsFailDebug("snapshotView was unexpectedly nil") - return nil - } - view.addSubview(snapshotView) - - guard let focusedViewSuperview = focusedView.superview else { - owsFailDebug("focusedViewSuperview was unexpectedly nil") - return nil - } - - let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview) - snapshotView.frame = convertedFrame - - return snapshotView - } - - private func animatePresentation() { - guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else { - owsFailDebug("actionSheetViewVerticalConstraint was unexpectedly nil") - return - } - - guard let focusedViewSuperview = focusedView.superview else { - owsFailDebug("focusedViewSuperview was unexpectedly nil") - return - } - - // darken background - guard let snapshotView = addSnapshotFocusedView() else { - owsFailDebug("snapshotView was unexpectedly nil") - return - } - - self.snapshotView = snapshotView - snapshotView.superview?.layoutIfNeeded() - - let backgroundDuration: TimeInterval = 0.1 - UIView.animate(withDuration: backgroundDuration) { - let alpha: CGFloat = isDarkMode ? 0.7 : 0.4 - self.view.backgroundColor = UIColor.black.withAlphaComponent(alpha) - } - - self.actionSheetView.superview?.layoutIfNeeded() - - let oldFocusFrame = self.view.convert(focusedView.frame, from: focusedViewSuperview) - NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint]) - self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(toSuperviewEdge: .bottom) - self.delegate?.menuActionsWillPresent(self) - UIView.animate(withDuration: 0.2, - delay: backgroundDuration, - options: .curveEaseOut, - animations: { - self.actionSheetView.superview?.layoutIfNeeded() - let newSheetFrame = self.actionSheetView.frame - - var newFocusFrame = oldFocusFrame - - // Position focused item just over the action sheet. - let overlap: CGFloat = (oldFocusFrame.maxY + self.vSpacing) - newSheetFrame.minY - newFocusFrame.origin.y = oldFocusFrame.origin.y - overlap - - snapshotView.frame = newFocusFrame - - self.delegate?.menuActionsIsPresenting(self) - }, - completion: { (_) in - self.delegate?.menuActionsDidPresent(self) - }) - } - - @objc - public let vSpacing: CGFloat = 10 - - @objc - public var focusUI: UIView { - return actionSheetView - } - - private func animateDismiss(action: MenuAction?) { - guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else { - owsFailDebug("actionSheetVerticalConstraint was unexpectedly nil") - delegate?.menuActionsDidDismiss(self) - return - } - - guard let snapshotView = self.snapshotView else { - owsFailDebug("snapshotView was unexpectedly nil") - delegate?.menuActionsDidDismiss(self) - return - } - - self.actionSheetView.superview?.layoutIfNeeded() - NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint]) - - let dismissDuration: TimeInterval = 0.2 - self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view) - UIView.animate(withDuration: dismissDuration, - delay: 0, - options: .curveEaseOut, - animations: { - self.view.backgroundColor = UIColor.clear - self.actionSheetView.superview?.layoutIfNeeded() - // this helps when focused view is above navbars, etc. - snapshotView.alpha = 0 - - self.delegate?.menuActionsIsDismissing(self) - }, - completion: { _ in - self.view.isHidden = true - self.delegate?.menuActionsDidDismiss(self) - if let action = action { - action.block(action) - } - }) - } - - // MARK: Actions - - @objc - func didTapBackground() { - animateDismiss(action: nil) - } - - // MARK: MenuActionSheetDelegate - - func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction) { - animateDismiss(action: action) - } -} - -protocol MenuActionSheetDelegate: class { - func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction) -} - -class MenuActionSheetView: UIView, MenuActionViewDelegate { - - private let actionStackView: UIStackView - private var actions: [MenuAction] - private var actionViews: [MenuActionView] - private var hapticFeedback: SelectionHapticFeedback - private var hasEverHighlightedAction = false - - weak var delegate: MenuActionSheetDelegate? - - override var bounds: CGRect { - didSet { - updateMask() - } - } - - convenience init(actions: [MenuAction]) { - self.init(frame: CGRect.zero) - actions.forEach { self.addAction($0) } - } - - override init(frame: CGRect) { - actionStackView = UIStackView() - actionStackView.axis = .vertical - actionStackView.spacing = CGHairlineWidth() - - actions = [] - actionViews = [] - hapticFeedback = SelectionHapticFeedback() - - super.init(frame: frame) - - backgroundColor = (isDarkMode - ? UIColor.ows_gray90 - : UIColor.ows_gray05) - addSubview(actionStackView) - actionStackView.autoPinEdgesToSuperviewEdges() - - self.clipsToBounds = true - - let touchGesture = UILongPressGestureRecognizer(target: self, action: #selector(didTouch(gesture:))) - touchGesture.minimumPressDuration = 0.0 - touchGesture.allowableMovement = CGFloat.greatestFiniteMagnitude - self.addGestureRecognizer(touchGesture) - } - - required init?(coder aDecoder: NSCoder) { - notImplemented() - } - - @objc - public func didTouch(gesture: UIGestureRecognizer) { - switch gesture.state { - case .possible: - break - case .began: - let location = gesture.location(in: self) - highlightActionView(location: location, fromView: self) - case .changed: - let location = gesture.location(in: self) - highlightActionView(location: location, fromView: self) - case .ended: - Logger.debug("ended") - let location = gesture.location(in: self) - selectActionView(location: location, fromView: self) - case .cancelled: - Logger.debug("canceled") - unhighlightAllActionViews() - case .failed: - Logger.debug("failed") - unhighlightAllActionViews() - default: break - } - } - - public func addAction(_ action: MenuAction) { - actions.append(action) - - let actionView = MenuActionView(action: action) - actionView.delegate = self - actionViews.append(actionView) - - self.actionStackView.addArrangedSubview(actionView) - } - - // MARK: MenuActionViewDelegate - - func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction) { - self.delegate?.actionSheet(self, didSelectAction: action) - } - - // MARK: - - private func updateMask() { - let cornerRadius: CGFloat = 16 - let path: UIBezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius)) - let mask = CAShapeLayer() - mask.path = path.cgPath - self.layer.mask = mask - } - - private func unhighlightAllActionViews() { - for actionView in actionViews { - actionView.isHighlighted = false - } - } - - private func actionView(touchedBy touchPoint: CGPoint, fromView: UIView) -> MenuActionView? { - for actionView in actionViews { - let convertedPoint = actionView.convert(touchPoint, from: fromView) - if actionView.point(inside: convertedPoint, with: nil) { - return actionView - } - } - return nil - } - - private func highlightActionView(location: CGPoint, fromView: UIView) { - guard let touchedView = actionView(touchedBy: location, fromView: fromView) else { - unhighlightAllActionViews() - return - } - - if hasEverHighlightedAction, !touchedView.isHighlighted { - self.hapticFeedback.selectionChanged() - } - touchedView.isHighlighted = true - hasEverHighlightedAction = true - - self.actionViews.filter { $0 != touchedView }.forEach { $0.isHighlighted = false } - } - - private func selectActionView(location: CGPoint, fromView: UIView) { - guard let selectedView: MenuActionView = actionView(touchedBy: location, fromView: fromView) else { - unhighlightAllActionViews() - return - } - selectedView.isHighlighted = true - self.actionViews.filter { $0 != selectedView }.forEach { $0.isHighlighted = false } - delegate?.actionSheet(self, didSelectAction: selectedView.action) - } -} - -protocol MenuActionViewDelegate: class { - func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction) -} - -class MenuActionView: UIButton { - public weak var delegate: MenuActionViewDelegate? - public let action: MenuAction - - required init(action: MenuAction) { - self.action = action - - super.init(frame: CGRect.zero) - - isUserInteractionEnabled = true - backgroundColor = defaultBackgroundColor - - let textColor = isLightMode ? UIColor.black : UIColor.white - - var image = action.image - image = image.withRenderingMode(.alwaysTemplate) - let imageView = UIImageView(image: image) - imageView.tintColor = textColor.withAlphaComponent(Values.unimportantElementOpacity) - let imageWidth: CGFloat = 24 - imageView.autoSetDimensions(to: CGSize(width: imageWidth, height: imageWidth)) - imageView.isUserInteractionEnabled = false - - let titleLabel = UILabel() - titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) - titleLabel.textColor = textColor - titleLabel.text = action.title - titleLabel.isUserInteractionEnabled = false - - let subtitleLabel = UILabel() - subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize) - subtitleLabel.textColor = textColor.withAlphaComponent(Values.unimportantElementOpacity) - subtitleLabel.text = action.subtitle - subtitleLabel.isUserInteractionEnabled = false - - let textColumn = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) - textColumn.axis = .vertical - textColumn.alignment = .leading - textColumn.isUserInteractionEnabled = false - - let contentRow = UIStackView(arrangedSubviews: [imageView, textColumn]) - contentRow.axis = .horizontal - contentRow.alignment = .center - contentRow.spacing = 12 - contentRow.isLayoutMarginsRelativeArrangement = true - contentRow.layoutMargins = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16) - contentRow.isUserInteractionEnabled = false - - self.addSubview(contentRow) - contentRow.autoPinEdgesToSuperviewMargins() - contentRow.autoSetDimension(.height, toSize: 56, relation: .greaterThanOrEqual) - - self.isUserInteractionEnabled = false - } - - private var defaultBackgroundColor: UIColor { - return isLightMode ? UIColor(hex: 0xFCFCFC) : UIColor(hex: 0x1B1B1B) - } - - private var highlightedBackgroundColor: UIColor { - return isLightMode ? UIColor(hex: 0xDFDFDF) : UIColor(hex: 0x0C0C0C) - } - - override var isHighlighted: Bool { - didSet { - self.backgroundColor = isHighlighted ? highlightedBackgroundColor : defaultBackgroundColor - } - } - - @objc - func didPress(sender: Any) { - Logger.debug("") - self.delegate?.actionView(self, didSelectAction: action) - } - - required init?(coder aDecoder: NSCoder) { - notImplemented() - } -} diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift new file mode 100644 index 000000000..ff2b2e73d --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -0,0 +1,51 @@ + +final class DocumentView : UIView { + private let viewItem: ConversationViewItem + private let textColor: UIColor + + // MARK: Settings + private static let iconSize: CGFloat = 24 + private static let iconImageViewSize: CGFloat = 40 + + // MARK: Lifecycle + init(viewItem: ConversationViewItem, textColor: UIColor) { + self.viewItem = viewItem + self.textColor = textColor + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(viewItem:textColor:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(viewItem:textColor:) instead.") + } + + private func setUpViewHierarchy() { + guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return } + // Image view + let iconSize = DocumentView.iconSize + let icon = UIImage(named: "actionsheet_document_black")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) + let imageView = UIImageView(image: icon) + imageView.contentMode = .center + let iconImageViewSize = DocumentView.iconImageViewSize + imageView.set(.width, to: iconImageViewSize) + imageView.set(.height, to: iconImageViewSize) + // Body label + let titleLabel = UILabel() + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.text = attachment.sourceFilename ?? "File" + titleLabel.textColor = textColor + titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) + // Stack view + let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12) + addSubview(stackView) + stackView.pin(to: self, withInset: Values.smallSpacing) + } +} diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift new file mode 100644 index 000000000..f41b626d4 --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -0,0 +1,220 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +extension CGPoint { + + public func offsetBy(dx: CGFloat) -> CGPoint { + return CGPoint(x: x + dx, y: y) + } + + public func offsetBy(dy: CGFloat) -> CGPoint { + return CGPoint(x: x, y: y + dy) + } +} + +// MARK: - + +@objc +public enum LinkPreviewImageState: Int { + case none + case loading + case loaded + case invalid +} + +// MARK: - + +@objc +public protocol LinkPreviewState { + func isLoaded() -> Bool + func urlString() -> String? + func displayDomain() -> String? + func title() -> String? + func imageState() -> LinkPreviewImageState + func image() -> UIImage? +} + +// MARK: - + +@objc +public class LinkPreviewLoading: NSObject, LinkPreviewState { + + override init() { + } + + public func isLoaded() -> Bool { + return false + } + + public func urlString() -> String? { + return nil + } + + public func displayDomain() -> String? { + return nil + } + + public func title() -> String? { + return nil + } + + public func imageState() -> LinkPreviewImageState { + return .none + } + + public func image() -> UIImage? { + return nil + } +} + +// MARK: - + +@objc +public class LinkPreviewDraft: NSObject, LinkPreviewState { + private let linkPreviewDraft: OWSLinkPreviewDraft + + @objc + public required init(linkPreviewDraft: OWSLinkPreviewDraft) { + self.linkPreviewDraft = linkPreviewDraft + } + + public func isLoaded() -> Bool { + return true + } + + public func urlString() -> String? { + return linkPreviewDraft.urlString + } + + public func displayDomain() -> String? { + guard let displayDomain = linkPreviewDraft.displayDomain() else { + owsFailDebug("Missing display domain") + return nil + } + return displayDomain + } + + public func title() -> String? { + guard let value = linkPreviewDraft.title, + value.count > 0 else { + return nil + } + return value + } + + public func imageState() -> LinkPreviewImageState { + if linkPreviewDraft.jpegImageData != nil { + return .loaded + } else { + return .none + } + } + + public func image() -> UIImage? { + guard let jpegImageData = linkPreviewDraft.jpegImageData else { + return nil + } + guard let image = UIImage(data: jpegImageData) else { + owsFailDebug("Could not load image: \(jpegImageData.count)") + return nil + } + return image + } +} + +// MARK: - + +@objc +public class LinkPreviewSent: NSObject, LinkPreviewState { + private let linkPreview: OWSLinkPreview + private let imageAttachment: TSAttachment? + + @objc + public var imageSize: CGSize { + guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + return CGSize.zero + } + return attachmentStream.imageSize() + } + + @objc + public required init(linkPreview: OWSLinkPreview, + imageAttachment: TSAttachment?) { + self.linkPreview = linkPreview + self.imageAttachment = imageAttachment + } + + public func isLoaded() -> Bool { + return true + } + + public func urlString() -> String? { + guard let urlString = linkPreview.urlString else { + owsFailDebug("Missing url") + return nil + } + return urlString + } + + public func displayDomain() -> String? { + guard let displayDomain = linkPreview.displayDomain() else { + Logger.error("Missing display domain") + return nil + } + return displayDomain + } + + public func title() -> String? { + guard let value = linkPreview.title, + value.count > 0 else { + return nil + } + return value + } + + public func imageState() -> LinkPreviewImageState { + guard linkPreview.imageAttachmentId != nil else { + return .none + } + guard let imageAttachment = imageAttachment else { + owsFailDebug("Missing imageAttachment.") + return .none + } + guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + return .loading + } + guard attachmentStream.isImage, + attachmentStream.isValidImage else { + return .invalid + } + return .loaded + } + + public func image() -> UIImage? { + guard let attachmentStream = imageAttachment as? TSAttachmentStream else { + return nil + } + guard attachmentStream.isImage, + attachmentStream.isValidImage else { + return nil + } + guard let imageFilepath = attachmentStream.originalFilePath else { + owsFailDebug("Attachment is missing file path.") + return nil + } + guard let image = UIImage(contentsOfFile: imageFilepath) else { + owsFailDebug("Could not load image: \(imageFilepath)") + return nil + } + return image + } +} + +// MARK: - + +@objc +public protocol LinkPreviewViewDraftDelegate { + func linkPreviewCanCancel() -> Bool + func linkPreviewDidCancel() +} diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift new file mode 100644 index 000000000..a9bd6f3c5 --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -0,0 +1,174 @@ +import NVActivityIndicatorView + +final class LinkPreviewView : UIView { + private let viewItem: ConversationViewItem? + private let maxWidth: CGFloat + private let delegate: LinkPreviewViewDelegate + var linkPreviewState: LinkPreviewState? { didSet { update() } } + private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100) + private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100) + + private lazy var sentLinkPreviewTextColor: UIColor = { + let isOutgoing = (viewItem!.interaction.interactionType() == .outgoingMessage) + switch (isOutgoing, AppModeManager.shared.currentAppMode) { + case (true, .dark), (false, .light): return .black + default: return .white + } + }() + + // MARK: UI Components + private lazy var imageView: UIImageView = { + let result = UIImageView() + result.contentMode = .scaleAspectFill + return result + }() + + private lazy var imageViewContainer: UIView = { + let result = UIView() + result.clipsToBounds = true + return result + }() + + private lazy var loader: NVActivityIndicatorView = { + let color: UIColor = isLightMode ? .black : .white + return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil) + }() + + private lazy var titleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.numberOfLines = 0 + return result + }() + + private lazy var bodyTextViewContainer = UIView() + + private lazy var hStackViewContainer = UIView() + + private lazy var hStackView = UIStackView() + + private lazy var cancelButton: UIButton = { + let result = UIButton(type: .custom) + let tint: UIColor = isLightMode ? .black : .white + result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) + let cancelButtonSize = LinkPreviewView.cancelButtonSize + result.set(.width, to: cancelButtonSize) + result.set(.height, to: cancelButtonSize) + result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) + return result + }() + + // MARK: Settings + private static let loaderSize: CGFloat = 24 + private static let cancelButtonSize: CGFloat = 45 + + // MARK: Lifecycle + init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) { + self.viewItem = viewItem + self.maxWidth = maxWidth + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(for:maxWidth:delegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(for:maxWidth:delegate:) instead.") + } + + private func setUpViewHierarchy() { + // Image view + imageViewContainerWidthConstraint.isActive = true + imageViewContainerHeightConstraint.isActive = true + imageViewContainer.addSubview(imageView) + imageView.pin(to: imageViewContainer) + // Title label + let titleLabelContainer = UIView() + titleLabelContainer.addSubview(titleLabel) + titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing) + // Horizontal stack view + hStackView.addArrangedSubview(imageViewContainer) + hStackView.addArrangedSubview(titleLabelContainer) + hStackView.axis = .horizontal + hStackView.alignment = .center + hStackViewContainer.addSubview(hStackView) + hStackView.pin(to: hStackViewContainer) + // Vertical stack view + let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ]) + vStackView.axis = .vertical + addSubview(vStackView) + vStackView.pin(to: self) + // Loader + addSubview(loader) + let loaderSize = LinkPreviewView.loaderSize + loader.set(.width, to: loaderSize) + loader.set(.height, to: loaderSize) + loader.center(in: self) + } + + // MARK: Updating + private func update() { + cancelButton.removeFromSuperview() + guard let linkPreviewState = linkPreviewState else { return } + var image = linkPreviewState.image() + if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) { + image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white) + } + // Image view + let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80 + imageViewContainerWidthConstraint.constant = imageViewContainerSize + imageViewContainerHeightConstraint.constant = imageViewContainerSize + imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8 + if linkPreviewState is LinkPreviewLoading { + imageViewContainer.backgroundColor = .clear + } else { + imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06) + } + imageView.image = image + imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill + // Loader + loader.alpha = (image != nil) ? 0 : 1 + if image != nil { loader.stopAnimating() } else { loader.startAnimating() } + // Title + let isSent = (linkPreviewState is LinkPreviewSent) + let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage) + let textColor: UIColor + if isSent && isOutgoing && isLightMode { + textColor = .white + } else { + textColor = isDarkMode ? .white : .black + } + titleLabel.textColor = textColor + titleLabel.text = linkPreviewState.title() + // Horizontal stack view + switch linkPreviewState { + case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06) + default: hStackViewContainer.backgroundColor = nil + } + // Body text view + bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } + if let viewItem = viewItem { + let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, searchText: delegate.lastSearchedText, delegate: delegate) + bodyTextViewContainer.addSubview(bodyTextView) + bodyTextView.pin(to: bodyTextViewContainer, withInset: 12) + } + if linkPreviewState is LinkPreviewDraft { + hStackView.addArrangedSubview(cancelButton) + } + } + + // MARK: Interaction + @objc private func cancel() { + delegate.handleLinkPreviewCanceled() + } +} + +// MARK: Delegate +protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate { + var lastSearchedText: String? { get } + + func handleLinkPreviewCanceled() +} diff --git a/Session/Conversations/Views & Cells/MediaAlbumCellView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift similarity index 82% rename from Session/Conversations/Views & Cells/MediaAlbumCellView.swift rename to Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 969254144..8fdd9f266 100644 --- a/Session/Conversations/Views & Cells/MediaAlbumCellView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -4,15 +4,15 @@ import Foundation -@objc(OWSMediaAlbumCellView) -public class MediaAlbumCellView: UIStackView { +@objc(OWSMediaAlbumView) +public class MediaAlbumView: UIStackView { private let items: [ConversationMediaAlbumItem] @objc - public let itemViews: [ConversationMediaView] + public let itemViews: [MediaView] @objc - public var moreItemsView: ConversationMediaView? + public var moreItemsView: MediaView? private static let kSpacingPts: CGFloat = 2 private static let kMaxItems = 5 @@ -26,22 +26,20 @@ public class MediaAlbumCellView: UIStackView { public required init(mediaCache: NSCache, items: [ConversationMediaAlbumItem], isOutgoing: Bool, - maxMessageWidth: CGFloat, - isOnionRouted: Bool) { + maxMessageWidth: CGFloat) { self.items = items - self.itemViews = MediaAlbumCellView.itemsToDisplay(forItems: items).map { - let result = ConversationMediaView(mediaCache: mediaCache, + self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map { + let result = MediaView(mediaCache: mediaCache, attachment: $0.attachment, isOutgoing: isOutgoing, - maxMessageWidth: maxMessageWidth, - isOnionRouted: isOnionRouted) + maxMessageWidth: maxMessageWidth) return result } super.init(frame: .zero) // UIStackView's backgroundColor property has no effect. - addBackgroundView(withBackgroundColor: Theme.backgroundColor) + addBackgroundView(withBackgroundColor: Colors.navigationBarBackground) createContents(maxMessageWidth: maxMessageWidth) } @@ -62,19 +60,19 @@ public class MediaAlbumCellView: UIStackView { case 2: // X X // side-by-side. - let imageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts) / 2 + let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 autoSet(viewSize: imageSize, ofViews: itemViews) for itemView in itemViews { addArrangedSubview(itemView) } self.axis = .horizontal - self.spacing = MediaAlbumCellView.kSpacingPts + self.spacing = MediaAlbumView.kSpacingPts case 3: // x // X x // Big on left, 2 small on right. - let smallImageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts * 2) / 3 - let bigImageSize = smallImageSize * 2 + MediaAlbumCellView.kSpacingPts + let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 + let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts guard let leftItemView = itemViews.first else { owsFailDebug("Missing view") @@ -88,12 +86,12 @@ public class MediaAlbumCellView: UIStackView { axis: .vertical, viewSize: smallImageSize)) self.axis = .horizontal - self.spacing = MediaAlbumCellView.kSpacingPts + self.spacing = MediaAlbumView.kSpacingPts case 4: // X X // X X // Square - let imageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts) / 2 + let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 let topViews = Array(itemViews[0..<2]) addArrangedSubview(newRow(rowViews: topViews, @@ -106,13 +104,13 @@ public class MediaAlbumCellView: UIStackView { viewSize: imageSize)) self.axis = .vertical - self.spacing = MediaAlbumCellView.kSpacingPts + self.spacing = MediaAlbumView.kSpacingPts default: // X X // xxx // 2 big on top, 3 small on bottom. - let bigImageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts) / 2 - let smallImageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts * 2) / 3 + let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 + let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 let topViews = Array(itemViews[0..<2]) addArrangedSubview(newRow(rowViews: topViews, @@ -125,9 +123,9 @@ public class MediaAlbumCellView: UIStackView { viewSize: smallImageSize)) self.axis = .vertical - self.spacing = MediaAlbumCellView.kSpacingPts + self.spacing = MediaAlbumView.kSpacingPts - if items.count > MediaAlbumCellView.kMaxItems { + if items.count > MediaAlbumView.kMaxItems { guard let lastView = bottomViews.last else { owsFailDebug("Missing lastView") return @@ -140,7 +138,7 @@ public class MediaAlbumCellView: UIStackView { lastView.addSubview(tintView) tintView.autoPinEdgesToSuperviewEdges() - let moreCount = max(1, items.count - MediaAlbumCellView.kMaxItems) + let moreCount = max(1, items.count - MediaAlbumView.kMaxItems) let moreCountText = OWSFormat.formatInt(Int32(moreCount)) let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT", comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText) @@ -184,24 +182,24 @@ public class MediaAlbumCellView: UIStackView { } private func autoSet(viewSize: CGFloat, - ofViews views: [ConversationMediaView]) { + ofViews views: [MediaView]) { for itemView in views { itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize)) } } - private func newRow(rowViews: [ConversationMediaView], + private func newRow(rowViews: [MediaView], axis: NSLayoutConstraint.Axis, viewSize: CGFloat) -> UIStackView { autoSet(viewSize: viewSize, ofViews: rowViews) return newRow(rowViews: rowViews, axis: axis) } - private func newRow(rowViews: [ConversationMediaView], + private func newRow(rowViews: [MediaView], axis: NSLayoutConstraint.Axis) -> UIStackView { let stackView = UIStackView(arrangedSubviews: rowViews) stackView.axis = axis - stackView.spacing = MediaAlbumCellView.kSpacingPts + stackView.spacing = MediaAlbumView.kSpacingPts return stackView } @@ -267,8 +265,8 @@ public class MediaAlbumCellView: UIStackView { } @objc - public func mediaView(forLocation location: CGPoint) -> ConversationMediaView? { - var bestMediaView: ConversationMediaView? + public func mediaView(forLocation location: CGPoint) -> MediaView? { + var bestMediaView: MediaView? var bestDistance: CGFloat = 0 for itemView in itemViews { let itemCenter = convert(itemView.center, from: itemView.superview) @@ -283,7 +281,7 @@ public class MediaAlbumCellView: UIStackView { } @objc - public func isMoreItemsView(mediaView: ConversationMediaView) -> Bool { + public func isMoreItemsView(mediaView: MediaView) -> Bool { return moreItemsView == mediaView } } diff --git a/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift b/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift new file mode 100644 index 000000000..ee4897e34 --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/MediaLoaderView.swift @@ -0,0 +1,78 @@ + +final class MediaLoaderView : UIView { + private let bar = UIView() + + private lazy var barLeftConstraint = bar.pin(.left, to: .left, of: self) + private lazy var barRightConstraint = bar.pin(.right, to: .right, of: self) + + // MARK: Lifecycle + override init(frame: CGRect) { + super.init(frame: frame) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + bar.backgroundColor = Colors.accent + bar.set(.height, to: 8) + addSubview(bar) + barLeftConstraint.isActive = true + bar.pin(.top, to: .top, of: self) + barRightConstraint.isActive = true + bar.pin(.bottom, to: .bottom, of: self) + step1() + } + + // MARK: Animation + func step1() { + barRightConstraint.constant = -bounds.width + UIView.animate(withDuration: 0.5, animations: { [weak self] in + guard let self = self else { return } + self.barRightConstraint.constant = 0 + self.layoutIfNeeded() + }, completion: { [weak self] _ in + self?.step2() + }) + } + + func step2() { + barLeftConstraint.constant = 0 + UIView.animate(withDuration: 0.5, animations: { [weak self] in + guard let self = self else { return } + self.barLeftConstraint.constant = self.bounds.width + self.layoutIfNeeded() + }, completion: { [weak self] _ in + Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in + self?.step3() + } + }) + } + + func step3() { + barLeftConstraint.constant = bounds.width + UIView.animate(withDuration: 0.5, animations: { [weak self] in + guard let self = self else { return } + self.barLeftConstraint.constant = 0 + self.layoutIfNeeded() + }, completion: { [weak self] _ in + self?.step4() + }) + } + + func step4() { + barRightConstraint.constant = 0 + UIView.animate(withDuration: 0.5, animations: { [weak self] in + guard let self = self else { return } + self.barRightConstraint.constant = -self.bounds.width + self.layoutIfNeeded() + }, completion: { [weak self] _ in + Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in + self?.step1() + } + }) + } +} diff --git a/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift b/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift new file mode 100644 index 000000000..b67f089cb --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/MediaTextOverlayView.swift @@ -0,0 +1,78 @@ + +final class MediaTextOverlayView : UIView { + private let viewItem: ConversationViewItem + private let albumViewWidth: CGFloat + private let delegate: MessageCellDelegate + var readMoreButton: UIButton? + + // MARK: Settings + private static let maxHeight: CGFloat = 88; + + // MARK: Lifecycle + init(viewItem: ConversationViewItem, albumViewWidth: CGFloat, delegate: MessageCellDelegate) { + self.viewItem = viewItem + self.albumViewWidth = albumViewWidth + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(text:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(text:) instead.") + } + + private func setUpViewHierarchy() { + guard let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0 else { return } + // Shadow + let shadowView = GradientView(from: .clear, to: UIColor.black.withAlphaComponent(0.7)) + addSubview(shadowView) + shadowView.pin(to: self) + // Line + let lineView = UIView() + lineView.backgroundColor = Colors.accent + lineView.set(.width, to: Values.accentLineThickness) + // Body label + let bodyLabel = UILabel() + bodyLabel.numberOfLines = 0 + bodyLabel.lineBreakMode = .byTruncatingTail + bodyLabel.text = given(body) { MentionUtilities.highlightMentions(in: $0, threadID: viewItem.interaction.uniqueThreadId) } + bodyLabel.textColor = .white + bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) + // Content stack view + let contentStackView = UIStackView(arrangedSubviews: [ lineView, bodyLabel ]) + contentStackView.axis = .horizontal + contentStackView.spacing = Values.smallSpacing + addSubview(contentStackView) + let inset = Values.mediumSpacing + contentStackView.pin(.left, to: .left, of: self, withInset: inset) + contentStackView.pin(.top, to: .top, of: self, withInset: 3 * inset) + contentStackView.pin(.right, to: .right, of: self, withInset: -inset) + // Max height + bodyLabel.heightAnchor.constraint(lessThanOrEqualToConstant: MediaTextOverlayView.maxHeight).isActive = true + // Overflow button + let bodyLabelTargetSize = bodyLabel.sizeThatFits(CGSize(width: albumViewWidth - 2 * inset, height: .greatestFiniteMagnitude)) + if bodyLabelTargetSize.height > MediaTextOverlayView.maxHeight { + let readMoreButton = UIButton() + self.readMoreButton = readMoreButton + readMoreButton.setTitle("Read More", for: UIControl.State.normal) + readMoreButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) + readMoreButton.setTitleColor(.white, for: UIControl.State.normal) + readMoreButton.addTarget(self, action: #selector(readMore), for: UIControl.Event.touchUpInside) + addSubview(readMoreButton) + readMoreButton.pin(.left, to: .left, of: self, withInset: inset) + readMoreButton.pin(.top, to: .bottom, of: contentStackView, withInset: Values.smallSpacing) + readMoreButton.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing) + } else { + contentStackView.pin(.bottom, to: .bottom, of: self, withInset: -inset) + } + } + + // MARK: Interaction + @objc private func readMore() { + delegate.showFullText(viewItem) + } +} diff --git a/Session/Conversations/Views & Cells/ConversationMediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift similarity index 87% rename from Session/Conversations/Views & Cells/ConversationMediaView.swift rename to Session/Conversations/Message Cells/Content Views/MediaView.swift index fc57923a8..9c9e7b77b 100644 --- a/Session/Conversations/Views & Cells/ConversationMediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -4,8 +4,8 @@ import Foundation -@objc(OWSConversationMediaView) -public class ConversationMediaView: UIView { +@objc(OWSMediaView) +public class MediaView: UIView { private enum MediaError { case missing @@ -22,7 +22,6 @@ public class ConversationMediaView: UIView { private let maxMessageWidth: CGFloat private var loadBlock: (() -> Void)? private var unloadBlock: (() -> Void)? - private let isOnionRouted: Bool // MARK: - LoadState @@ -85,17 +84,15 @@ public class ConversationMediaView: UIView { public required init(mediaCache: NSCache, attachment: TSAttachment, isOutgoing: Bool, - maxMessageWidth: CGFloat, - isOnionRouted: Bool) { + maxMessageWidth: CGFloat) { self.mediaCache = mediaCache self.attachment = attachment self.isOutgoing = isOutgoing self.maxMessageWidth = maxMessageWidth - self.isOnionRouted = isOnionRouted super.init(frame: .zero) - backgroundColor = Theme.offBackgroundColor + backgroundColor = Colors.unimportant clipsToBounds = true createContents() @@ -152,53 +149,19 @@ public class ConversationMediaView: UIView { configure(forError: .missing) return } - guard let attachmentId = attachmentPointer.uniqueId else { - owsFailDebug("Attachment missing unique ID.") - configure(forError: .invalid) - return - } - /* - guard nil != attachmentDownloads.downloadProgress(forAttachmentId: attachmentId) else { - // Not being downloaded. - configure(forError: .missing) - return - } - */ - backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05) - let view: UIView - if isOnionRouted { // Loki: Due to the way onion routing works we can't get upload progress for those attachments - let activityIndicatorView = UIActivityIndicatorView(style: .white) - activityIndicatorView.isHidden = false - activityIndicatorView.startAnimating() - view = activityIndicatorView - } else { - view = MediaDownloadView(attachmentId: attachmentId, radius: maxMessageWidth * 0.1) - } - addSubview(view) - view.autoPinEdgesToSuperviewEdges() + let loader = MediaLoaderView() + addSubview(loader) + loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) } private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { guard isOutgoing else { return false } guard let attachmentStream = attachment as? TSAttachmentStream else { return false } - guard let attachmentId = attachmentStream.uniqueId else { - owsFailDebug("Attachment missing unique ID.") - configure(forError: .invalid) - return false - } guard !attachmentStream.isUploaded else { return false } - let view: UIView - if isOnionRouted { // Loki: Due to the way onion routing works we can't get upload progress for those attachments - let activityIndicatorView = UIActivityIndicatorView(style: .white) - activityIndicatorView.isHidden = false - activityIndicatorView.startAnimating() - view = activityIndicatorView - } else { - view = MediaUploadView(attachmentId: attachmentId, radius: maxMessageWidth * 0.1) - } - addSubview(view) - view.autoPinEdgesToSuperviewEdges() + let loader = MediaLoaderView() + addSubview(loader) + loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) return true } @@ -215,7 +178,7 @@ public class ConversationMediaView: UIView { // some performance cost. animatedImageView.layer.minificationFilter = .trilinear animatedImageView.layer.magnificationFilter = .trilinear - animatedImageView.backgroundColor = Theme.offBackgroundColor + animatedImageView.backgroundColor = Colors.unimportant addSubview(animatedImageView) animatedImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(animatedImageView) @@ -274,7 +237,7 @@ public class ConversationMediaView: UIView { // some performance cost. stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear - stillImageView.backgroundColor = Theme.offBackgroundColor + stillImageView.backgroundColor = Colors.unimportant addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(stillImageView) @@ -329,7 +292,7 @@ public class ConversationMediaView: UIView { // some performance cost. stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear - stillImageView.backgroundColor = Theme.offBackgroundColor + stillImageView.backgroundColor = Colors.unimportant addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() @@ -408,7 +371,7 @@ public class ConversationMediaView: UIView { return } let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) - iconView.tintColor = Theme.primaryColor.withAlphaComponent(0.6) + iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addSubview(iconView) iconView.autoCenterInSuperview() } @@ -457,7 +420,7 @@ public class ConversationMediaView: UIView { Logger.verbose("media cache miss") let threadSafeLoadState = self.threadSafeLoadState - ConversationMediaView.loadQueue.async { + MediaView.loadQueue.async { guard threadSafeLoadState.get() == .loading else { Logger.verbose("Skipping obsolete load.") return diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift new file mode 100644 index 000000000..12e9b46bd --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -0,0 +1,254 @@ + +final class QuoteView : UIView { + private let mode: Mode + private let direction: Direction + private let hInset: CGFloat + private let maxWidth: CGFloat + private let delegate: QuoteViewDelegate? + + private var maxBodyLabelHeight: CGFloat { + switch mode { + case .regular: return 60 + case .draft: return 40 + } + } + + private var attachments: [OWSAttachmentInfo] { + switch mode { + case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? [] + case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? [] + } + } + + private var thumbnail: UIImage? { + switch mode { + case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage + case .draft(let model): return model.thumbnailImage + } + } + + private var body: String? { + switch mode { + case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body + case .draft(let model): return model.body + } + } + + private var threadID: String { + switch mode { + case .regular(let viewItem): return viewItem.interaction.uniqueThreadId + case .draft(let model): return model.threadId + } + } + + private var isGroupThread: Bool { + switch mode { + case .regular(let viewItem): return viewItem.isGroupThread + case .draft(let model): + var result = false + Storage.read { transaction in + result = TSThread.fetch(uniqueId: model.threadId, transaction: transaction)?.isGroupThread() ?? false + } + return result + } + } + + private var authorID: String { + switch mode { + case .regular(let viewItem): return viewItem.quotedReply!.authorId + case .draft(let model): return model.authorId + } + } + + private var lineColor: UIColor { + switch (mode, AppModeManager.shared.currentAppMode) { + case (.regular, .light), (.draft, .light): return .black + case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent + case (.draft, .dark): return Colors.accent + } + } + + private var textColor: UIColor { + if case .draft = mode { return Colors.text } + switch (direction, AppModeManager.shared.currentAppMode) { + case (.outgoing, .dark), (.incoming, .light): return .black + default: return .white + } + } + + // MARK: Mode + enum Mode { + case regular(ConversationViewItem) + case draft(OWSQuotedReplyModel) + } + + // MARK: Direction + enum Direction { case incoming, outgoing } + + // MARK: Settings + static let thumbnailSize: CGFloat = 48 + static let iconSize: CGFloat = 24 + static let labelStackViewSpacing: CGFloat = 2 + static let labelStackViewVMargin: CGFloat = 4 + static let cancelButtonSize: CGFloat = 33 + + // MARK: Lifecycle + init(for viewItem: ConversationViewItem, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) { + self.mode = .regular(viewItem) + self.maxWidth = maxWidth + self.direction = direction + self.hInset = hInset + self.delegate = nil + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) { + self.mode = .draft(model) + self.maxWidth = maxWidth + self.direction = direction + self.hInset = hInset + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(for:maxMessageWidth:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(for:maxMessageWidth:) instead.") + } + + private func setUpViewHierarchy() { + let hasAttachments = !attachments.isEmpty + let thumbnailSize = QuoteView.thumbnailSize + let iconSize = QuoteView.iconSize + let labelStackViewSpacing = QuoteView.labelStackViewSpacing + let labelStackViewVMargin = QuoteView.labelStackViewVMargin + let smallSpacing = Values.smallSpacing + let cancelButtonSize = QuoteView.cancelButtonSize + var availableWidth: CGFloat + // Subtract smallSpacing twice; once for the spacing in between the stack view elements and + // once for the trailing margin. + if !hasAttachments { + availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing + } else { + availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing + } + if case .draft = mode { + availableWidth -= cancelButtonSize + } + let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) + var body = self.body + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: []) + mainStackView.axis = .horizontal + mainStackView.spacing = smallSpacing + mainStackView.isLayoutMarginsRelativeArrangement = true + mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing) + mainStackView.alignment = .center + // Content view + let contentView = UIView() + addSubview(contentView) + contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self) + contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true + // Line view + let lineView = UIView() + lineView.backgroundColor = lineColor + lineView.set(.width, to: Values.accentLineThickness) + if !hasAttachments { + mainStackView.addArrangedSubview(lineView) + } else { + let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType!) + let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black" + let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) + let imageView = UIImageView(image: thumbnail ?? fallbackImage) + imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center + imageView.backgroundColor = lineColor + imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius + imageView.layer.masksToBounds = true + imageView.set(.width, to: thumbnailSize) + imageView.set(.height, to: thumbnailSize) + mainStackView.addArrangedSubview(imageView) + body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document") + } + // Body label + let bodyLabel = UILabel() + bodyLabel.numberOfLines = 0 + bodyLabel.lineBreakMode = .byTruncatingTail + let isOutgoing = (direction == .outgoing) + bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) + bodyLabel.attributedText = given(body) { MentionUtilities.highlightMentions(in: $0, isOutgoingMessage: isOutgoing, threadID: threadID, attributes: [:]) } + ?? given(attachments.first?.contentType) { NSAttributedString(string: MIMETypeUtil.isAudio($0) ? "Audio" : "Document") } ?? NSAttributedString(string: "Document") + bodyLabel.textColor = textColor + if hasAttachments { + bodyLabel.numberOfLines = 1 + } + let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) + // Label stack view + var authorLabelHeight: CGFloat? + if isGroupThread { + let authorLabel = UILabel() + authorLabel.lineBreakMode = .byTruncatingTail + authorLabel.text = SSKEnvironment.shared.profileManager.profileNameForRecipient(withID: authorID, avoidingWriteTransaction: true) + authorLabel.textColor = textColor + authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) + let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) + authorLabel.set(.height, to: authorLabelSize.height) + authorLabelHeight = authorLabelSize.height + let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) + labelStackView.axis = .vertical + labelStackView.spacing = labelStackViewSpacing + labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width)) + labelStackView.isLayoutMarginsRelativeArrangement = true + labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) + mainStackView.addArrangedSubview(labelStackView) + } else { + mainStackView.addArrangedSubview(bodyLabel) + } + // Cancel button + let cancelButton = UIButton(type: .custom) + let tint: UIColor = isLightMode ? .black : .white + cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) + cancelButton.set(.width, to: cancelButtonSize) + cancelButton.set(.height, to: cancelButtonSize) + cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) + // Constraints + contentView.addSubview(mainStackView) + mainStackView.pin(to: contentView) + if !isGroupThread { + bodyLabel.set(.width, to: bodyLabelSize.width) + } + let bodyLabelHeight = bodyLabelSize.height.clamp(0, maxBodyLabelHeight) + let contentViewHeight: CGFloat + if hasAttachments { + contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail + } else { + if let authorLabelHeight = authorLabelHeight { // Group thread + contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin + } else { + contentViewHeight = bodyLabelHeight + 2 * smallSpacing + } + } + contentView.set(.height, to: contentViewHeight) + lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line + if case .draft = mode { + addSubview(cancelButton) + cancelButton.center(.vertical, in: self) + cancelButton.pin(.right, to: .right, of: self) + } + } + + // MARK: Interaction + @objc private func cancel() { + delegate?.handleQuoteViewCancelButtonTapped() + } +} + +// MARK: Delegate +protocol QuoteViewDelegate { + + func handleQuoteViewCancelButtonTapped() +} diff --git a/Session/Conversations/Views & Cells/TypingIndicatorView.swift b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift similarity index 100% rename from Session/Conversations/Views & Cells/TypingIndicatorView.swift rename to Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift diff --git a/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift b/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift new file mode 100644 index 000000000..aca5e4c43 --- /dev/null +++ b/Session/Conversations/Message Cells/Content Views/VoiceMessageView.swift @@ -0,0 +1,173 @@ +import NVActivityIndicatorView + +@objc(SNVoiceMessageView) +public final class VoiceMessageView : UIView { + private let viewItem: ConversationViewItem + private var isShowingSpeedUpLabel = false + @objc var progress: Int = 0 { didSet { handleProgressChanged() } } + @objc var isPlaying = false { didSet { handleIsPlayingChanged() } } + + private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width) + + private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer } + private var duration: Int { Int(viewItem.audioDurationSeconds) } + + // MARK: UI Components + private lazy var progressView: UIView = { + let result = UIView() + result.backgroundColor = UIColor.black.withAlphaComponent(0.2) + return result + }() + + private lazy var toggleImageView: UIImageView = { + let result = UIImageView(image: UIImage(named: "Play")) + result.set(.width, to: 8) + result.set(.height, to: 8) + result.contentMode = .scaleAspectFit + return result + }() + + private lazy var loader: NVActivityIndicatorView = { + let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil) + result.set(.width, to: VoiceMessageView.toggleContainerSize + 2) + result.set(.height, to: VoiceMessageView.toggleContainerSize + 2) + return result + }() + + private lazy var countdownLabelContainer: UIView = { + let result = UIView() + result.backgroundColor = .white + result.layer.masksToBounds = true + result.set(.height, to: VoiceMessageView.toggleContainerSize) + result.set(.width, to: 44) + return result + }() + + private lazy var countdownLabel: UILabel = { + let result = UILabel() + result.textColor = .black + result.font = .systemFont(ofSize: Values.smallFontSize) + result.text = "00:00" + return result + }() + + private lazy var speedUpLabel: UILabel = { + let result = UILabel() + result.textColor = .black + result.font = .systemFont(ofSize: Values.smallFontSize) + result.alpha = 0 + result.text = "1.5x" + result.textAlignment = .center + return result + }() + + // MARK: Settings + private static let width: CGFloat = 160 + private static let toggleContainerSize: CGFloat = 20 + private static let inset = Values.smallSpacing + + // MARK: Lifecycle + init(viewItem: ConversationViewItem) { + self.viewItem = viewItem + self.progress = Int(viewItem.audioProgressSeconds) + super.init(frame: CGRect.zero) + setUpViewHierarchy() + handleProgressChanged() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(viewItem:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(viewItem:) instead.") + } + + private func setUpViewHierarchy() { + let toggleContainerSize = VoiceMessageView.toggleContainerSize + let inset = VoiceMessageView.inset + // Width & height + set(.width, to: VoiceMessageView.width) + // Toggle + let toggleContainer = UIView() + toggleContainer.backgroundColor = .white + toggleContainer.set(.width, to: toggleContainerSize) + toggleContainer.set(.height, to: toggleContainerSize) + toggleContainer.addSubview(toggleImageView) + toggleImageView.center(in: toggleContainer) + toggleContainer.layer.cornerRadius = toggleContainerSize / 2 + toggleContainer.layer.masksToBounds = true + // Line + let lineView = UIView() + lineView.backgroundColor = .white + lineView.set(.height, to: 1) + // Countdown label + countdownLabelContainer.addSubview(countdownLabel) + countdownLabel.center(in: countdownLabelContainer) + // Speed up label + countdownLabelContainer.addSubview(speedUpLabel) + speedUpLabel.center(in: countdownLabelContainer) + // Constraints + addSubview(progressView) + progressView.pin(.left, to: .left, of: self) + progressView.pin(.top, to: .top, of: self) + progressViewRightConstraint.isActive = true + progressView.pin(.bottom, to: .bottom, of: self) + addSubview(toggleContainer) + toggleContainer.pin(.left, to: .left, of: self, withInset: inset) + toggleContainer.pin(.top, to: .top, of: self, withInset: inset) + toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset) + addSubview(lineView) + lineView.pin(.left, to: .right, of: toggleContainer) + lineView.center(.vertical, in: self) + addSubview(countdownLabelContainer) + countdownLabelContainer.pin(.left, to: .right, of: lineView) + countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset) + countdownLabelContainer.center(.vertical, in: self) + addSubview(loader) + loader.center(in: toggleContainer) + } + + // MARK: Updating + public override func layoutSubviews() { + super.layoutSubviews() + countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2 + } + + private func handleIsPlayingChanged() { + toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play") + if !isPlaying { progress = 0 } + } + + private func handleProgressChanged() { + let isDownloaded = (attachment?.isDownloaded == true) + loader.isHidden = isDownloaded + if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() } + guard isDownloaded else { return } + countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress) + guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else { + return progressViewRightConstraint.constant = -VoiceMessageView.width + } + let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds + progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction)) + } + + func showSpeedUpLabel() { + guard !isShowingSpeedUpLabel else { return } + isShowingSpeedUpLabel = true + UIView.animate(withDuration: 0.25) { [weak self] in + guard let self = self else { return } + self.countdownLabel.alpha = 0 + self.speedUpLabel.alpha = 1 + } + Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in + UIView.animate(withDuration: 0.25, animations: { + guard let self = self else { return } + self.countdownLabel.alpha = 1 + self.speedUpLabel.alpha = 0 + }, completion: { _ in + self?.isShowingSpeedUpLabel = false + }) + } + } +} diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift new file mode 100644 index 000000000..c7873cbc1 --- /dev/null +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -0,0 +1,71 @@ + +final class InfoMessageCell : MessageCell { + private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize) + private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize) + + // MARK: UI Components + private lazy var iconImageView = UIImageView() + + private lazy var label: UILabel = { + let result = UILabel() + result.numberOfLines = 0 + result.lineBreakMode = .byWordWrapping + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.textAlignment = .center + return result + }() + + private lazy var stackView: UIStackView = { + let result = UIStackView(arrangedSubviews: [ iconImageView, label ]) + result.axis = .vertical + result.alignment = .center + result.spacing = Values.smallSpacing + return result + }() + + // MARK: Settings + private static let iconSize: CGFloat = 16 + private static let inset = Values.mediumSpacing + + override class var identifier: String { "InfoMessageCell" } + + // MARK: Lifecycle + override func setUpViewHierarchy() { + super.setUpViewHierarchy() + iconImageViewWidthConstraint.isActive = true + iconImageViewHeightConstraint.isActive = true + addSubview(stackView) + stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset) + stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset) + stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset) + stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset) + } + + // MARK: Updating + override func update() { + guard let message = viewItem?.interaction as? TSInfoMessage else { return } + let icon: UIImage? + switch message.messageType { + case .typeDisappearingMessagesUpdate: + var configuration: OWSDisappearingMessagesConfiguration? + Storage.read { transaction in + configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction) + } + if let configuration = configuration { + icon = configuration.isEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled") + } else { + icon = nil + } + default: icon = nil + } + if let icon = icon { + iconImageView.image = icon.withTint(Colors.text) + } + iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 + iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 + Storage.read { transaction in + self.label.text = message.previewText(with: transaction) + } + } +} diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift new file mode 100644 index 000000000..733afa3ce --- /dev/null +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -0,0 +1,61 @@ +import UIKit + +class MessageCell : UITableViewCell { + var delegate: MessageCellDelegate? + var viewItem: ConversationViewItem? { didSet { update() } } + + // MARK: Settings + class var identifier: String { preconditionFailure("Must be overridden by subclasses.") } + + // MARK: Lifecycle + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() + setUpGestureRecognizers() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + setUpGestureRecognizers() + } + + func setUpViewHierarchy() { + backgroundColor = .clear + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = .clear + self.selectedBackgroundView = selectedBackgroundView + } + + func setUpGestureRecognizers() { + // To be overridden by subclasses + } + + // MARK: Updating + func update() { + preconditionFailure("Must be overridden by subclasses.") + } + + // MARK: Convenience + static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type { + switch viewItem.interaction { + case is TSIncomingMessage: fallthrough + case is TSOutgoingMessage: return VisibleMessageCell.self + case is TSInfoMessage: return InfoMessageCell.self + case is TypingIndicatorInteraction: return TypingIndicatorCell.self + default: preconditionFailure() + } + } +} + +protocol MessageCellDelegate { + var lastSearchedText: String? { get } + + func getMediaCache() -> NSCache + func handleViewItemLongPressed(_ viewItem: ConversationViewItem) + func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) + func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) + func showFullText(_ viewItem: ConversationViewItem) + func openURL(_ url: URL) + func handleReplyButtonTapped(for viewItem: ConversationViewItem) +} diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift new file mode 100644 index 000000000..87be75649 --- /dev/null +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -0,0 +1,85 @@ + +// Assumptions +// • We'll never encounter an outgoing typing indicator. +// • Typing indicators are only sent in contact threads. + +final class TypingIndicatorCell : MessageCell { + + private var positionInCluster: Position? { + guard let viewItem = viewItem else { return nil } + if viewItem.isFirstInCluster { return .top } + if viewItem.isLastInCluster { return .bottom } + return .middle + } + + private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true } + + // MARK: UI Components + private lazy var bubbleView: UIView = { + let result = UIView() + result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius + result.backgroundColor = Colors.receivedMessageBackground + return result + }() + + private let bubbleViewMaskLayer = CAShapeLayer() + + private lazy var typingIndicatorView = TypingIndicatorView() + + // MARK: Settings + override class var identifier: String { "TypingIndicatorCell" } + + // MARK: Direction & Position + enum Position { case top, middle, bottom } + + // MARK: Lifecycle + override func setUpViewHierarchy() { + super.setUpViewHierarchy() + // Bubble view + addSubview(bubbleView) + bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing) + bubbleView.pin(.top, to: .top, of: self, withInset: 1) + // Typing indicator view + bubbleView.addSubview(typingIndicatorView) + typingIndicatorView.pin(to: bubbleView, withInset: 12) + } + + // MARK: Updating + override func update() { + guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return } + // Bubble view + updateBubbleViewCorners() + // Typing indicator view + typingIndicatorView.startAnimation() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateBubbleViewCorners() + } + + private func updateBubbleViewCorners() { + let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(), + cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) + bubbleViewMaskLayer.path = maskPath.cgPath + bubbleView.layer.mask = bubbleViewMaskLayer + } + + override func prepareForReuse() { + super.prepareForReuse() + typingIndicatorView.stopAnimation() + } + + // MARK: Convenience + private func getCornersToRound() -> UIRectCorner { + guard !isOnlyMessageInCluster else { return .allCorners } + let result: UIRectCorner + switch positionInCluster { + case .top: result = [ .topLeft, .topRight, .bottomRight ] + case .middle: result = [ .topRight, .bottomRight ] + case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ] + case nil: result = .allCorners + } + return result + } +} diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift new file mode 100644 index 000000000..8e991caa4 --- /dev/null +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -0,0 +1,608 @@ + +final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { + private var unloadContent: (() -> Void)? + private var previousX: CGFloat = 0 + var albumView: MediaAlbumView? + var bodyTextView: UITextView? + var mediaTextOverlayView: MediaTextOverlayView? + // Constraints + private lazy var headerViewTopConstraint = headerView.pin(.top, to: .top, of: self, withInset: 1) + private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) + private lazy var profilePictureViewLeftConstraint = profilePictureView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.groupThreadHSpacing) + private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: Values.verySmallProfilePictureSize) + private lazy var bubbleViewLeftConstraint1 = bubbleView.pin(.left, to: .right, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing) + private lazy var bubbleViewLeftConstraint2 = bubbleView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, constant: VisibleMessageCell.gutterSize) + private lazy var bubbleViewTopConstraint = bubbleView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing) + private lazy var bubbleViewRightConstraint1 = bubbleView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) + private lazy var bubbleViewRightConstraint2 = bubbleView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize) + private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: bubbleView, withInset: 0) + private lazy var messageStatusImageViewWidthConstraint = messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize) + private lazy var messageStatusImageViewHeightConstraint = messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize) + private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing) + private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) + + private lazy var panGestureRecognizer: UIPanGestureRecognizer = { + let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + result.delegate = self + return result + }() + + var lastSearchedText: String? { delegate?.lastSearchedText } + + private var positionInCluster: Position? { + guard let viewItem = viewItem else { return nil } + if viewItem.isFirstInCluster { return .top } + if viewItem.isLastInCluster { return .bottom } + return .middle + } + + private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true } + + private var direction: Direction { + guard let message = viewItem?.interaction as? TSMessage else { preconditionFailure() } + switch message { + case is TSIncomingMessage: return .incoming + case is TSOutgoingMessage: return .outgoing + default: preconditionFailure() + } + } + + private var shouldInsetHeader: Bool { + guard let viewItem = viewItem else { preconditionFailure() } + return (positionInCluster == .top || isOnlyMessageInCluster) && !viewItem.wasPreviousItemInfoMessage + } + + // MARK: UI Components + private lazy var profilePictureView: ProfilePictureView = { + let result = ProfilePictureView() + let size = Values.verySmallProfilePictureSize + result.set(.height, to: size) + result.size = size + return result + }() + + private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) + + lazy var bubbleView: UIView = { + let result = UIView() + result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius + return result + }() + + private let bubbleViewMaskLayer = CAShapeLayer() + + private lazy var headerView = UIView() + + private lazy var authorLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.smallFontSize) + return result + }() + + private lazy var snContentView = UIView() + + private lazy var messageStatusImageView: UIImageView = { + let result = UIImageView() + result.contentMode = .scaleAspectFit + result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2 + result.layer.masksToBounds = true + return result + }() + + private lazy var replyButton: UIView = { + let result = UIView() + let size = VisibleMessageCell.replyButtonSize + 8 + result.set(.width, to: size) + result.set(.height, to: size) + result.layer.borderWidth = 1 + result.layer.borderColor = Colors.text.cgColor + result.layer.cornerRadius = size / 2 + result.layer.masksToBounds = true + result.alpha = 0 + return result + }() + + private lazy var replyIconImageView: UIImageView = { + let result = UIImageView() + let size = VisibleMessageCell.replyButtonSize + result.set(.width, to: size) + result.set(.height, to: size) + result.image = UIImage(named: "ic_reply")!.withTint(Colors.text) + return result + }() + + private lazy var timerView = OWSMessageTimerView() + + // MARK: Settings + private static let messageStatusImageViewSize: CGFloat = 16 + private static let authorLabelBottomSpacing: CGFloat = 4 + private static let groupThreadHSpacing: CGFloat = 12 + private static let profilePictureSize = Values.verySmallProfilePictureSize + private static let authorLabelInset: CGFloat = 12 + private static let replyButtonSize: CGFloat = 24 + private static let maxBubbleTranslationX: CGFloat = 40 + private static let swipeToReplyThreshold: CGFloat = 130 + static let smallCornerRadius: CGFloat = 4 + static let largeCornerRadius: CGFloat = 18 + static let contactThreadHSpacing = Values.mediumSpacing + + static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing } + + private var bodyLabelTextColor: UIColor { + switch (direction, AppModeManager.shared.currentAppMode) { + case (.outgoing, .dark), (.incoming, .light): return .black + default: return .white + } + } + + override class var identifier: String { "VisibleMessageCell" } + + // MARK: Direction & Position + enum Direction { case incoming, outgoing } + enum Position { case top, middle, bottom } + + // MARK: Lifecycle + override func setUpViewHierarchy() { + super.setUpViewHierarchy() + // Header view + addSubview(headerView) + headerViewTopConstraint.isActive = true + headerView.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) + // Author label + addSubview(authorLabel) + authorLabelHeightConstraint.isActive = true + authorLabel.pin(.top, to: .bottom, of: headerView) + // Profile picture view + addSubview(profilePictureView) + profilePictureViewLeftConstraint.isActive = true + profilePictureViewWidthConstraint.isActive = true + profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -1) + // Moderator icon image view + moderatorIconImageView.set(.width, to: 20) + moderatorIconImageView.set(.height, to: 20) + addSubview(moderatorIconImageView) + moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) + moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Bubble view + addSubview(bubbleView) + bubbleViewLeftConstraint1.isActive = true + bubbleViewTopConstraint.isActive = true + bubbleViewRightConstraint1.isActive = true + // Timer view + addSubview(timerView) + timerView.center(.vertical, in: bubbleView) + timerViewOutgoingMessageConstraint.isActive = true + // Content view + bubbleView.addSubview(snContentView) + snContentView.pin(to: bubbleView) + // Message status image view + addSubview(messageStatusImageView) + messageStatusImageViewTopConstraint.isActive = true + messageStatusImageView.pin(.right, to: .right, of: bubbleView, withInset: -1) + messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1) + messageStatusImageViewWidthConstraint.isActive = true + messageStatusImageViewHeightConstraint.isActive = true + // Reply button + addSubview(replyButton) + replyButton.addSubview(replyIconImageView) + replyIconImageView.center(in: replyButton) + replyButton.pin(.left, to: .right, of: bubbleView, withInset: Values.smallSpacing) + replyButton.center(.vertical, in: bubbleView) + // Remaining constraints + authorLabel.pin(.left, to: .left, of: bubbleView, withInset: VisibleMessageCell.authorLabelInset) + } + + override func setUpGestureRecognizers() { + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + addGestureRecognizer(longPressRecognizer) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tapGestureRecognizer.numberOfTapsRequired = 1 + addGestureRecognizer(tapGestureRecognizer) + let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) + doubleTapGestureRecognizer.numberOfTapsRequired = 2 + addGestureRecognizer(doubleTapGestureRecognizer) + tapGestureRecognizer.require(toFail: doubleTapGestureRecognizer) + addGestureRecognizer(panGestureRecognizer) + } + + // MARK: Updating + override func update() { + guard let viewItem = viewItem, let message = viewItem.interaction as? TSMessage else { return } + let thread = message.thread + let isGroupThread = thread.isGroupThread() + // Profile picture view + profilePictureViewLeftConstraint.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0 + profilePictureViewWidthConstraint.constant = isGroupThread ? VisibleMessageCell.profilePictureSize : 0 + let senderSessionID = (message as? TSIncomingMessage)?.authorId + profilePictureView.isHidden = !VisibleMessageCell.shouldShowProfilePicture(for: viewItem) + if let senderSessionID = senderSessionID { + profilePictureView.update(for: senderSessionID) + } + if let thread = thread as? TSGroupThread, thread.isOpenGroup, + let openGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!), let senderSessionID = senderSessionID { + let isUserModerator = OpenGroupAPI.isUserModerator(senderSessionID, for: openGroup.channel, on: openGroup.server) + moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden + } else { + moderatorIconImageView.isHidden = true + } + // Bubble view + bubbleViewLeftConstraint1.isActive = (direction == .incoming) + bubbleViewLeftConstraint1.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing + bubbleViewLeftConstraint2.isActive = (direction == .outgoing) + bubbleViewTopConstraint.constant = (viewItem.senderName == nil) ? 0 : VisibleMessageCell.authorLabelBottomSpacing + bubbleViewRightConstraint1.isActive = (direction == .outgoing) + bubbleViewRightConstraint2.isActive = (direction == .incoming) + bubbleView.backgroundColor = (direction == .incoming) ? Colors.receivedMessageBackground : Colors.sentMessageBackground + updateBubbleViewCorners() + // Content view + populateContentView(for: viewItem) + // Date break + headerViewTopConstraint.constant = shouldInsetHeader ? Values.mediumSpacing : 1 + headerView.subviews.forEach { $0.removeFromSuperview() } + if viewItem.shouldShowDate { + populateHeader(for: viewItem) + } + // Author label + authorLabel.textColor = Colors.text + authorLabel.isHidden = (viewItem.senderName == nil) + authorLabel.text = viewItem.senderName?.string // Will only be set if it should be shown + let authorLabelAvailableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * VisibleMessageCell.authorLabelInset + let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) + let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) + authorLabelHeightConstraint.constant = (viewItem.senderName != nil) ? authorLabelSize.height : 0 + // Message status image view + let (image, backgroundColor) = getMessageStatusImage(for: message) + messageStatusImageView.image = image + messageStatusImageView.backgroundColor = backgroundColor + if let message = message as? TSOutgoingMessage { + messageStatusImageView.isHidden = (message.messageState == .sent && message.thread.lastInteraction != message) + } else { + messageStatusImageView.isHidden = true + } + messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden) ? 0 : 5 + [ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ].forEach { + $0.constant = (messageStatusImageView.isHidden) ? 0 : VisibleMessageCell.messageStatusImageViewSize + } + // Timer + if viewItem.isExpiringMessage { + let expirationTimestamp = message.expiresAt + let expiresInSeconds = message.expiresInSeconds + timerView.configure(withExpirationTimestamp: expirationTimestamp, initialDurationSeconds: expiresInSeconds, tintColor: Colors.text) + } + timerView.isHidden = !viewItem.isExpiringMessage + timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing) + timerViewIncomingMessageConstraint.isActive = (direction == .incoming) + } + + private func populateHeader(for viewItem: ConversationViewItem) { + guard viewItem.shouldShowDate else { return } + let dateBreakLabel = UILabel() + dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + dateBreakLabel.textColor = Colors.text + dateBreakLabel.textAlignment = .center + let date = viewItem.interaction.receivedAtDate() + let description = DateUtil.formatDate(forConversationDateBreaks: date) + dateBreakLabel.text = description + headerView.addSubview(dateBreakLabel) + dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing) + let additionalBottomInset = shouldInsetHeader ? Values.mediumSpacing : 1 + headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset) + dateBreakLabel.center(.horizontal, in: headerView) + let availableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) + let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) + let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace) + dateBreakLabel.set(.height, to: dateBreakLabelSize.height) + } + + private func populateContentView(for viewItem: ConversationViewItem) { + snContentView.subviews.forEach { $0.removeFromSuperview() } + albumView = nil + bodyTextView = nil + mediaTextOverlayView = nil + let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage) + switch viewItem.messageCellType { + case .textOnlyMessage: + let inset: CGFloat = 12 + let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset + if let linkPreview = viewItem.linkPreview { + let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self) + linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment) + snContentView.addSubview(linkPreviewView) + linkPreviewView.pin(to: snContentView) + } else { + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + // Quote view + if viewItem.quotedReply != nil { + let direction: QuoteView.Direction = isOutgoing ? .outgoing : .incoming + let hInset: CGFloat = 2 + let quoteView = QuoteView(for: viewItem, direction: direction, hInset: hInset, maxWidth: maxWidth) + let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) + stackView.addArrangedSubview(quoteViewContainer) + } + // Body text view + let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate?.lastSearchedText, delegate: self) + self.bodyTextView = bodyTextView + stackView.addArrangedSubview(bodyTextView) + // Constraints + snContentView.addSubview(stackView) + stackView.pin(to: snContentView, withInset: inset) + } + case .mediaMessage: + guard let cache = delegate?.getMediaCache() else { preconditionFailure() } + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem) + let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth) + self.albumView = albumView + snContentView.addSubview(albumView) + let size = getSize(for: viewItem) + albumView.set(.width, to: size.width) + albumView.set(.height, to: size.height) + albumView.pin(to: snContentView) + albumView.loadMedia() + albumView.layer.mask = bubbleViewMaskLayer + if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0, + let delegate = delegate { // delegate should always be set at this point + let overlayView = MediaTextOverlayView(viewItem: viewItem, albumViewWidth: size.width, delegate: delegate) + self.mediaTextOverlayView = overlayView + snContentView.addSubview(overlayView) + overlayView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: snContentView) + } + unloadContent = { albumView.unloadMedia() } + case .audio: + let voiceMessageView = VoiceMessageView(viewItem: viewItem) + snContentView.addSubview(voiceMessageView) + voiceMessageView.pin(to: snContentView) + viewItem.lastAudioMessageView = voiceMessageView + case .genericAttachment: + let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor) + snContentView.addSubview(documentView) + documentView.pin(to: snContentView) + default: return + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateBubbleViewCorners() + } + + private func updateBubbleViewCorners() { + let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(), + cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) + bubbleViewMaskLayer.path = maskPath.cgPath + bubbleView.layer.mask = bubbleViewMaskLayer + } + + override func prepareForReuse() { + super.prepareForReuse() + unloadContent?() + let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] + viewsToMove.forEach { $0.transform = .identity } + replyButton.alpha = 0 + timerView.prepareForReuse() + } + + // MARK: Interaction + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let bodyTextView = bodyTextView { + let pointInBodyTextViewCoordinates = convert(point, to: bodyTextView) + if bodyTextView.bounds.contains(pointInBodyTextViewCoordinates) { + return bodyTextView + } + } + return super.hitTest(point, with: event) + } + + override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer == panGestureRecognizer { + let v = panGestureRecognizer.velocity(in: self) + guard v.x < 0 else { return false } + return abs(v.x) > abs(v.y) + } else { + return true + } + } + + @objc func handleLongPress() { + guard let viewItem = viewItem else { return } + delegate?.handleViewItemLongPressed(viewItem) + } + + @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { + guard let viewItem = viewItem else { return } + let location = gestureRecognizer.location(in: self) + if replyButton.frame.contains(location) { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + reply() + } else { + delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) + } + } + + @objc private func handleDoubleTap() { + guard let viewItem = viewItem else { return } + delegate?.handleViewItemDoubleTapped(viewItem) + } + + @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { + let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] + let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) + switch gestureRecognizer.state { + case .changed: + let damping: CGFloat = 20 + let sign: CGFloat = -1 + let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign + viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } + replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + } + previousX = translationX + case .ended, .cancelled: + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { + reply() + } else { + resetReply() + } + default: break + } + } + + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + delegate?.openURL(URL) + return false + } + + private func resetReply() { + let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] + UIView.animate(withDuration: 0.25) { + viewsToMove.forEach { $0.transform = .identity } + self.replyButton.alpha = 0 + } + } + + private func reply() { + guard let viewItem = viewItem else { return } + resetReply() + delegate?.handleReplyButtonTapped(for: viewItem) + } + + func handleLinkPreviewCanceled() { + // Not relevant in this case + } + + // MARK: Convenience + private func getCornersToRound() -> UIRectCorner { + guard !isOnlyMessageInCluster else { return .allCorners } + let result: UIRectCorner + switch (positionInCluster, direction) { + case (.top, .outgoing): result = [ .bottomLeft, .topLeft, .topRight ] + case (.middle, .outgoing): result = [ .bottomLeft, .topLeft ] + case (.bottom, .outgoing): result = [ .bottomRight, .bottomLeft, .topLeft ] + case (.top, .incoming): result = [ .topLeft, .topRight, .bottomRight ] + case (.middle, .incoming): result = [ .topRight, .bottomRight ] + case (.bottom, .incoming): result = [ .topRight, .bottomRight, .bottomLeft ] + case (nil, _): result = .allCorners + } + return result + } + + private static func getFontSize(for viewItem: ConversationViewItem) -> CGFloat { + let baselineFontSize = Values.mediumFontSize + switch viewItem.displayableBodyText?.jumbomojiCount { + case 1: return baselineFontSize + 30 + case 2: return baselineFontSize + 24 + case 3, 4, 5: return baselineFontSize + 18 + default: return baselineFontSize + } + } + + private func getMessageStatusImage(for message: TSMessage) -> (image: UIImage?, backgroundColor: UIColor?) { + guard let message = message as? TSOutgoingMessage else { return (nil, nil) } + let image: UIImage + var backgroundColor: UIColor? = nil + let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: message) + switch status { + case .uploading, .sending: image = #imageLiteral(resourceName: "CircleDotDotDot").asTintedImage(color: Colors.text)! + case .sent, .skipped, .delivered: image = #imageLiteral(resourceName: "CircleCheck").asTintedImage(color: Colors.text)! + case .read: + backgroundColor = isLightMode ? .black : .white + image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode") + case .failed: image = #imageLiteral(resourceName: "message_status_failed").asTintedImage(color: Colors.destructive)! + } + return (image, backgroundColor) + } + + private func getSize(for viewItem: ConversationViewItem) -> CGSize { + guard let albumItems = viewItem.mediaAlbumItems else { preconditionFailure() } + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem) + let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: albumItems) + guard albumItems.count == 1 else { return defaultSize } + // Honor the content aspect ratio for single media + let albumItem = albumItems.first! + let size = albumItem.mediaSize + guard size.width > 0 && size.height > 0 else { return defaultSize } + var aspectRatio = (size.width / size.height) + // Clamp the aspect ratio so that very thin/wide content still looks alright + let minAspectRatio: CGFloat = 0.35 + let maxAspectRatio = 1 / minAspectRatio + aspectRatio = aspectRatio.clamp(minAspectRatio, maxAspectRatio) + let maxSize = CGSize(width: maxMessageWidth, height: maxMessageWidth) + var width = with(maxSize.height * aspectRatio) { $0 > maxSize.width ? maxSize.width : $0 } + var height = (width > maxSize.width) ? (maxSize.width / aspectRatio) : maxSize.height + // Don't blow up small images unnecessarily + let minSize: CGFloat = 150 + let shortSourceDimension = min(size.width, size.height) + let shortDestinationDimension = min(width, height) + if shortDestinationDimension > minSize && shortDestinationDimension > shortSourceDimension { + let factor = minSize / shortDestinationDimension + width *= factor; height *= factor + } + return CGSize(width: width, height: height) + } + + static func getMaxWidth(for viewItem: ConversationViewItem) -> CGFloat { + let screen = UIScreen.main.bounds + switch viewItem.interaction.interactionType() { + case .outgoingMessage: return screen.width - contactThreadHSpacing - gutterSize + case .incomingMessage: + let leftGutterSize = shouldShowProfilePicture(for: viewItem) ? gutterSize : contactThreadHSpacing + return screen.width - leftGutterSize - gutterSize + default: preconditionFailure() + } + } + + private static func shouldShowProfilePicture(for viewItem: ConversationViewItem) -> Bool { + guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } + let isGroupThread = message.thread.isGroupThread() + let senderSessionID = (message as? TSIncomingMessage)?.authorId + return isGroupThread && viewItem.shouldShowSenderProfilePicture && senderSessionID != nil + } + + static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView { + guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } + let isOutgoing = (message.interactionType() == .outgoingMessage) + let result = BodyTextView(snDelegate: delegate) + result.isEditable = false + let attributes: [NSAttributedString.Key:Any] = [ + .foregroundColor : textColor, + .font : UIFont.systemFont(ofSize: getFontSize(for: viewItem)) + ] + let attributedText = NSMutableAttributedString(attributedString: MentionUtilities.highlightMentions(in: message.body ?? "", isOutgoingMessage: isOutgoing, threadID: viewItem.interaction.uniqueThreadId, attributes: attributes)) + if let searchText = searchText, searchText.count >= ConversationSearchController.kMinimumSearchTextLength { + let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText) + do { + let regex = try NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: normalizedSearchText), options: .caseInsensitive) + let matches = regex.matches(in: attributedText.string, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: (attributedText.string as NSString).length)) + for match in matches { + guard match.range.location + match.range.length < attributedText.length else { continue } + attributedText.addAttribute(.backgroundColor, value: UIColor.white, range: match.range) + attributedText.addAttribute(.foregroundColor, value: UIColor.black, range: match.range) + } + } catch { + // Do nothing + } + } + result.attributedText = attributedText + result.dataDetectorTypes = .link + result.backgroundColor = .clear + result.isOpaque = false + result.textContainerInset = UIEdgeInsets.zero + result.contentInset = UIEdgeInsets.zero + result.textContainer.lineFragmentPadding = 0 + result.isScrollEnabled = false + result.isUserInteractionEnabled = true + result.delegate = delegate + result.linkTextAttributes = [ .foregroundColor : textColor, .underlineStyle : NSUnderlineStyle.single.rawValue ] + let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) + let size = result.sizeThatFits(availableSpace) + result.set(.height, to: size.height) + return result + } +} diff --git a/Session/Conversations/MessageActions.swift b/Session/Conversations/MessageActions.swift deleted file mode 100644 index 96fe41dc5..000000000 --- a/Session/Conversations/MessageActions.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -protocol MessageActionsDelegate: class { - func banUser(_ conversationViewItem: ConversationViewItem) - func messageActionsShowDetailsForItem(_ conversationViewItem: ConversationViewItem) - func messageActionsReplyToItem(_ conversationViewItem: ConversationViewItem) - func copyPublicKey(for conversationViewItem: ConversationViewItem) -} - -struct MessageActionBuilder { - - static func reply(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_reply"), - title: NSLocalizedString("MESSAGE_ACTION_REPLY", comment: "Action sheet button title"), - subtitle: nil, - block: { [weak delegate] _ in delegate?.messageActionsReplyToItem(conversationViewItem) } - ) - } - - static func copyText(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_copy"), - title: NSLocalizedString("MESSAGE_ACTION_COPY_TEXT", comment: "Action sheet button title"), - subtitle: nil, - block: { _ in conversationViewItem.copyTextAction() } - ) - } - - static func copyPublicKey(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "Key").scaled(to: CGSize(width: 24, height: 24)), - title: NSLocalizedString("Copy Session ID", comment: ""), - subtitle: nil, - block: { [weak delegate] _ in delegate?.copyPublicKey(for: conversationViewItem) } - ) - } - - static func showDetails(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_info"), - title: NSLocalizedString("MESSAGE_ACTION_DETAILS", comment: "Action sheet button title"), - subtitle: nil, - block: { [weak delegate] _ in delegate?.messageActionsShowDetailsForItem(conversationViewItem) } - ) - } - - static func deleteMessage(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_trash"), - title: NSLocalizedString("MESSAGE_ACTION_DELETE_MESSAGE", comment: "Action sheet button title"), - subtitle: nil, - block: { _ in conversationViewItem.deleteAction() } - ) - } - - static func banUser(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_block"), - title: "Ban User", - subtitle: nil, - block: { [weak delegate] _ in delegate?.banUser(conversationViewItem) } - ) - } - - static func copyMedia(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_copy"), - title: NSLocalizedString("MESSAGE_ACTION_COPY_MEDIA", comment: "Action sheet button title"), - subtitle: nil, - block: { _ in conversationViewItem.copyMediaAction() } - ) - } - - static func saveMedia(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction { - return MenuAction(image: #imageLiteral(resourceName: "ic_download"), - title: NSLocalizedString("MESSAGE_ACTION_SAVE_MEDIA", comment: "Action sheet button title"), - subtitle: nil, - block: { _ in conversationViewItem.saveMediaAction() } - ) - } -} - -@objc -class ConversationViewItemActions: NSObject { - - @objc - class func textActions(conversationViewItem: ConversationViewItem, shouldAllowReply: Bool, delegate: MessageActionsDelegate) -> [MenuAction] { - var actions: [MenuAction] = [] - - let isGroup = conversationViewItem.isGroupThread; - - if shouldAllowReply { - let replyAction = MessageActionBuilder.reply(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(replyAction) - } - - if conversationViewItem.hasBodyTextActionContent { - let copyTextAction = MessageActionBuilder.copyText(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(copyTextAction) - } - - if isGroup && conversationViewItem.interaction is TSIncomingMessage { - let copyPublicKeyAction = MessageActionBuilder.copyPublicKey(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(copyPublicKeyAction) - } - - if !isGroup || conversationViewItem.userCanDeleteGroupMessage { - let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(deleteAction) - } - - if isGroup && conversationViewItem.interaction is TSIncomingMessage && conversationViewItem.userHasModerationPermission { - let banAction = MessageActionBuilder.banUser(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(banAction) - } - - let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(showDetailsAction) - - return actions - } - - @objc - class func mediaActions(conversationViewItem: ConversationViewItem, shouldAllowReply: Bool, delegate: MessageActionsDelegate) -> [MenuAction] { - var actions: [MenuAction] = [] - - let isGroup = conversationViewItem.isGroupThread; - - if shouldAllowReply { - let replyAction = MessageActionBuilder.reply(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(replyAction) - } - - if conversationViewItem.hasMediaActionContent { - if conversationViewItem.canCopyMedia() { - let copyMediaAction = MessageActionBuilder.copyMedia(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(copyMediaAction) - } - if conversationViewItem.canSaveMedia() { - let saveMediaAction = MessageActionBuilder.saveMedia(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(saveMediaAction) - } - } - - if isGroup && conversationViewItem.interaction is TSIncomingMessage { - let copyPublicKeyAction = MessageActionBuilder.copyPublicKey(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(copyPublicKeyAction) - } - - if !isGroup || conversationViewItem.userCanDeleteGroupMessage { - let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(deleteAction) - } - - if isGroup && conversationViewItem.interaction is TSIncomingMessage && conversationViewItem.userHasModerationPermission { - let banAction = MessageActionBuilder.banUser(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(banAction) - } - - let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(showDetailsAction) - - return actions - } - - @objc - class func quotedMessageActions(conversationViewItem: ConversationViewItem, shouldAllowReply: Bool, delegate: MessageActionsDelegate) -> [MenuAction] { - var actions: [MenuAction] = [] - - if shouldAllowReply { - let replyAction = MessageActionBuilder.reply(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(replyAction) - } - - let isGroup = conversationViewItem.isGroupThread; - - if isGroup && conversationViewItem.interaction is TSIncomingMessage { - let copyPublicKeyAction = MessageActionBuilder.copyPublicKey(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(copyPublicKeyAction) - } - - if !isGroup || conversationViewItem.userCanDeleteGroupMessage { - let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(deleteAction) - } - - if isGroup && conversationViewItem.interaction is TSIncomingMessage && conversationViewItem.userHasModerationPermission { - let banAction = MessageActionBuilder.banUser(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(banAction) - } - - let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: conversationViewItem, delegate: delegate) - actions.append(showDetailsAction) - - return actions - } - - @objc - class func infoMessageActions(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> [MenuAction] { - let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate) - return [deleteAction ] - } -} diff --git a/Session/Conversations/MessageDetailViewController.swift b/Session/Conversations/MessageDetailViewController.swift deleted file mode 100644 index 89d6cdcf2..000000000 --- a/Session/Conversations/MessageDetailViewController.swift +++ /dev/null @@ -1,728 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation -import SignalUtilitiesKit -import SignalUtilitiesKit - -@objc -enum MessageMetadataViewMode: UInt { - case focusOnMessage - case focusOnMetadata -} - -@objc -protocol MessageDetailViewDelegate: AnyObject { - func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController) -} - -@objc -class MessageDetailViewController: OWSViewController, MediaGalleryDataSourceDelegate, OWSMessageBubbleViewDelegate { - - @objc - weak var delegate: MessageDetailViewDelegate? - - // MARK: Properties - - let uiDatabaseConnection: YapDatabaseConnection - - var bubbleView: UIView? - - let mode: MessageMetadataViewMode - let viewItem: ConversationViewItem - var message: TSMessage - var wasDeleted: Bool = false - - var messageBubbleView: OWSMessageBubbleView? - var messageBubbleViewWidthLayoutConstraint: NSLayoutConstraint? - var messageBubbleViewHeightLayoutConstraint: NSLayoutConstraint? - - var scrollView: UIScrollView! - var contentView: UIView? - - var attachment: TSAttachment? - var dataSource: DataSource? - var attachmentStream: TSAttachmentStream? - var messageBody: String? - - lazy var shouldShowUD: Bool = { - return self.preferences.shouldShowUnidentifiedDeliveryIndicators() - }() - - var conversationStyle: ConversationStyle - - // MARK: Dependencies - - var preferences: OWSPreferences { - return Environment.shared.preferences - } - - // MARK: Initializers - - @available(*, unavailable, message:"use other constructor instead.") - required init?(coder aDecoder: NSCoder) { - notImplemented() - } - - @objc - required init(viewItem: ConversationViewItem, message: TSMessage, thread: TSThread, mode: MessageMetadataViewMode) { - self.viewItem = viewItem - self.message = message - self.mode = mode - self.uiDatabaseConnection = OWSPrimaryStorage.shared().uiDatabaseConnection - self.conversationStyle = ConversationStyle(thread: thread) - - super.init(nibName: nil, bundle: nil) - } - - // MARK: View Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - do { - try updateMessageToLatest() - } catch DetailViewError.messageWasDeleted { - self.delegate?.detailViewMessageWasDeleted(self) - } catch { - owsFailDebug("unexpected error") - } - - self.conversationStyle.viewWidth = view.width() - - ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_METADATA_VIEW_TITLE", comment: "Title for the 'message metadata' view."), hasCustomBackButton: false) - - createViews() - - self.view.layoutIfNeeded() - - NotificationCenter.default.addObserver(self, - selector: #selector(uiDatabaseDidUpdate), - name: .OWSUIDatabaseConnectionDidUpdate, - object: OWSPrimaryStorage.shared().dbNotificationObject) - } - - override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - Logger.debug("") - - super.viewWillTransition(to: size, with: coordinator) - - self.conversationStyle.viewWidth = size.width - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - updateMessageBubbleViewLayout() - - if mode == .focusOnMetadata { - if let bubbleView = self.bubbleView { - // Force layout. - view.setNeedsLayout() - view.layoutIfNeeded() - - let contentHeight = scrollView.contentSize.height - let scrollViewHeight = scrollView.frame.size.height - guard contentHeight >= scrollViewHeight else { - // All content is visible within the scroll view. No need to offset. - return - } - - // We want to include at least a little portion of the message, but scroll no farther than necessary. - let showAtLeast: CGFloat = 50 - let bubbleViewBottom = bubbleView.superview!.convert(bubbleView.frame, to: scrollView).maxY - let maxOffset = bubbleViewBottom - showAtLeast - let lastPage = contentHeight - scrollViewHeight - - let offset = CGPoint(x: 0, y: min(maxOffset, lastPage)) - - scrollView.setContentOffset(offset, animated: false) - } - } - } - - // MARK: - Create Views - - private func createViews() { - view.backgroundColor = .clear - - let scrollView = UIScrollView() - self.scrollView = scrollView - view.addSubview(scrollView) - scrollView.autoPinWidthToSuperview(withMargin: 0) - - if scrollView.applyInsetsFix() { - scrollView.autoPinEdge(.top, to: .top, of: view) - } else { - scrollView.autoPinEdge(toSuperviewEdge: .top) - } - - let contentView = UIView.container() - self.contentView = contentView - scrollView.addSubview(contentView) - contentView.autoPinLeadingToSuperviewMargin() - contentView.autoPinTrailingToSuperviewMargin() - contentView.autoPinEdge(toSuperviewEdge: .top) - contentView.autoPinEdge(toSuperviewEdge: .bottom) - scrollView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) - scrollView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) - - if hasMediaAttachment { - let footer = UIToolbar() - view.addSubview(footer) - footer.autoPinWidthToSuperview(withMargin: 0) - footer.autoPinEdge(.top, to: .bottom, of: scrollView) - footer.autoPinEdge(.bottom, to: .bottom, of: view) - - footer.items = [ - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), - UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareButtonPressed)), - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - ] - } else { - scrollView.autoPinEdge(toSuperviewEdge: .bottom) - } - - updateContent() - } - - lazy var thread: TSThread = { - var thread: TSThread? - self.uiDatabaseConnection.read { transaction in - thread = self.message.thread(with: transaction) - } - return thread! - }() - - private func updateContent() { - guard let contentView = contentView else { - owsFailDebug("Missing contentView") - return - } - - // Remove any existing content views. - for subview in contentView.subviews { - subview.removeFromSuperview() - } - - var rows = [UIView]() - - // Content - rows += contentRows() - - // Sender? - if let incomingMessage = message as? TSIncomingMessage { - let senderId = incomingMessage.authorId - let threadID = thread.uniqueId! - var senderName: String! - Storage.writeSync { transaction in - senderName = DisplayNameUtilities2.getDisplayName(for: senderId, inThreadWithID: threadID, using: transaction) - } - rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_SENDER", - comment: "Label for the 'sender' field of the 'message metadata' view."), - value: senderName)) - } - - // Recipient(s) - if let outgoingMessage = message as? TSOutgoingMessage { - - func getSeparator() -> UIView { - let result = UIView() - result.set(.height, to: Values.separatorThickness) - result.backgroundColor = Colors.separator - return result - } - - if !outgoingMessage.recipientIds().isEmpty { - rows += [ getSeparator() ] - } - - rows += outgoingMessage.recipientIds().flatMap { publicKey -> [UIView] in - // We use ContactCellView, not ContactTableViewCell. - // Table view cells don't layout properly outside the - // context of a table view. - let cellView = ContactCellView() - cellView.configure(withRecipientId: publicKey) - let wrapper = UIView() - wrapper.layoutMargins = UIEdgeInsets(top: 8, left: 20, bottom: 8, right: 20) - wrapper.addSubview(cellView) - cellView.autoPinEdgesToSuperviewMargins() - return [ wrapper, getSeparator() ] - } - - if !outgoingMessage.recipientIds().isEmpty { - rows += [ UIView.vSpacer(10) ] - } - } - - let sentText = DateUtil.formatPastTimestampRelativeToNow(message.timestamp) - let sentRow: UIStackView = valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_SENT_DATE_TIME", - comment: "Label for the 'sent date & time' field of the 'message metadata' view."), - value: sentText) - if let incomingMessage = message as? TSIncomingMessage { - if self.shouldShowUD, incomingMessage.wasReceivedByUD { - let icon = #imageLiteral(resourceName: "ic_secret_sender_indicator").withRenderingMode(.alwaysTemplate) - let iconView = UIImageView(image: icon) - iconView.tintColor = Theme.secondaryColor - iconView.setContentHuggingHigh() - sentRow.addArrangedSubview(iconView) - // keep the icon close to the label. - let spacerView = UIView() - spacerView.setContentHuggingLow() - sentRow.addArrangedSubview(spacerView) - } - } - - sentRow.isUserInteractionEnabled = true - sentRow.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongPressSent))) - rows.append(sentRow) - - if message is TSIncomingMessage { - rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_RECEIVED_DATE_TIME", - comment: "Label for the 'received date & time' field of the 'message metadata' view."), - value: DateUtil.formatPastTimestampRelativeToNow(message.receivedAtTimestamp))) - } - - rows += addAttachmentMetadataRows() - - // TODO: We could include the "disappearing messages" state here. - - let rowStack = UIStackView(arrangedSubviews: rows) - rowStack.axis = .vertical - rowStack.spacing = 5 - contentView.addSubview(rowStack) - rowStack.autoPinEdgesToSuperviewMargins() - contentView.layoutIfNeeded() - updateMessageBubbleViewLayout() - } - - private func displayableTextIfText() -> String? { - guard viewItem.hasBodyText else { - return nil - } - guard let displayableText = viewItem.displayableBodyText else { - return nil - } - let messageBody = displayableText.fullText - guard messageBody.count > 0 else { - return nil - } - return messageBody - } - - let bubbleViewHMargin: CGFloat = 10 - - private func contentRows() -> [UIView] { - var rows = [UIView]() - - let messageBubbleView = OWSMessageBubbleView(frame: CGRect.zero) - messageBubbleView.delegate = self - messageBubbleView.addTapGestureHandler() - self.messageBubbleView = messageBubbleView - messageBubbleView.viewItem = viewItem - messageBubbleView.cellMediaCache = NSCache() - messageBubbleView.conversationStyle = conversationStyle - messageBubbleView.configureViews() - messageBubbleView.loadContent() - - assert(messageBubbleView.isUserInteractionEnabled) - - let row = UIView() - row.addSubview(messageBubbleView) - messageBubbleView.autoPinHeightToSuperview() - - let isIncoming = self.message as? TSIncomingMessage != nil - messageBubbleView.autoPinEdge(toSuperviewEdge: isIncoming ? .leading : .trailing, withInset: bubbleViewHMargin) - - self.messageBubbleViewWidthLayoutConstraint = messageBubbleView.autoSetDimension(.width, toSize: 0) - self.messageBubbleViewHeightLayoutConstraint = messageBubbleView.autoSetDimension(.height, toSize: 0) - rows.append(row) - - if rows.isEmpty { - // Neither attachment nor body. - owsFailDebug("Message has neither attachment nor body.") - rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_NO_ATTACHMENT_OR_BODY", - comment: "Label for messages without a body or attachment in the 'message metadata' view."), - value: "")) - } - - let spacer = UIView() - spacer.autoSetDimension(.height, toSize: 15) - rows.append(spacer) - - return rows - } - - private func fetchAttachment(transaction: YapDatabaseReadTransaction) -> TSAttachment? { - // TODO: Support multi-image messages. - guard let attachmentId = message.attachmentIds.firstObject as? String else { - return nil - } - - guard let attachment = TSAttachment.fetch(uniqueId: attachmentId, transaction: transaction) else { - Logger.warn("Missing attachment. Was it deleted?") - return nil - } - - return attachment - } - - var hasMediaAttachment: Bool { - guard let attachment = self.attachment else { - return false - } - - guard attachment.contentType != OWSMimeTypeOversizeTextMessage else { - // to the user, oversized text attachments should behave - // just like regular text messages. - return false - } - - return true - } - - private func addAttachmentMetadataRows() -> [UIView] { - guard hasMediaAttachment else { - return [] - } - - var rows = [UIView]() - - if let attachment = self.attachment { - // Only show MIME types in DEBUG builds. - if _isDebugAssertConfiguration() { - let contentType = attachment.contentType - rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_ATTACHMENT_MIME_TYPE", - comment: "Label for the MIME type of attachments in the 'message metadata' view."), - value: contentType)) - } - - if let sourceFilename = attachment.sourceFilename { - rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_SOURCE_FILENAME", - comment: "Label for the original filename of any attachment in the 'message metadata' view."), - value: sourceFilename)) - } - } - - if let dataSource = self.dataSource { - let fileSize = dataSource.dataLength() - rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_ATTACHMENT_FILE_SIZE", - comment: "Label for file size of attachments in the 'message metadata' view."), - value: OWSFormat.formatFileSize(UInt(fileSize)))) - } - - return rows - } - - private func buildUDAccessoryView(text: String) -> UIView { - let label = UILabel() - label.textColor = Theme.secondaryColor - label.text = text - label.textAlignment = .right - label.font = UIFont.ows_mediumFont(withSize: 13) - - let image = #imageLiteral(resourceName: "ic_secret_sender_indicator").withRenderingMode(.alwaysTemplate) - let imageView = UIImageView(image: image) - imageView.tintColor = Theme.middleGrayColor - - let hStack = UIStackView(arrangedSubviews: [imageView, label]) - hStack.axis = .horizontal - hStack.spacing = 8 - - return hStack - } - - private func nameLabel(text: String) -> UILabel { - let label = UILabel() - label.textColor = Theme.primaryColor - label.font = UIFont.ows_mediumFont(withSize: 14) - label.text = text - label.setContentHuggingHorizontalHigh() - return label - } - - private func valueLabel(text: String) -> UILabel { - let label = UILabel() - label.textColor = Theme.primaryColor - label.font = UIFont.ows_regularFont(withSize: 14) - label.text = text - label.setContentHuggingHorizontalLow() - return label - } - - private func valueRow(name: String, value: String, subtitle: String = "") -> UIStackView { - let nameLabel = self.nameLabel(text: name) - let valueLabel = self.valueLabel(text: value) - let hStackView = UIStackView(arrangedSubviews: [nameLabel, valueLabel]) - hStackView.axis = .horizontal - hStackView.spacing = 10 - hStackView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) - hStackView.isLayoutMarginsRelativeArrangement = true - - if subtitle.count > 0 { - let subtitleLabel = self.valueLabel(text: subtitle) - subtitleLabel.textColor = Theme.secondaryColor - hStackView.addArrangedSubview(subtitleLabel) - } - - return hStackView - } - - // MARK: - Actions - - @objc func shareButtonPressed() { - guard let attachmentStream = attachmentStream else { - Logger.error("Share button should only be shown with attachment, but no attachment found.") - return - } - AttachmentSharing.showShareUI(forAttachment: attachmentStream) - } - - // MARK: - Actions - - enum DetailViewError: Error { - case messageWasDeleted - } - - // This method should be called after self.databaseConnection.beginLongLivedReadTransaction(). - private func updateMessageToLatest() throws { - - AssertIsOnMainThread() - - try self.uiDatabaseConnection.read { transaction in - guard let uniqueId = self.message.uniqueId else { - Logger.error("Message is missing uniqueId.") - return - } - guard let newMessage = TSInteraction.fetch(uniqueId: uniqueId, transaction: transaction) as? TSMessage else { - Logger.error("Message was deleted") - throw DetailViewError.messageWasDeleted - } - self.message = newMessage - self.attachment = self.fetchAttachment(transaction: transaction) - self.attachmentStream = self.attachment as? TSAttachmentStream - } - } - - @objc internal func uiDatabaseDidUpdate(notification: NSNotification) { - AssertIsOnMainThread() - - guard !wasDeleted else { - // Item was deleted in the tile view gallery. - // Don't bother re-rendering, it will fail and we'll soon be dismissed. - return - } - - guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else { - owsFailDebug("notifications was unexpectedly nil") - return - } - - guard let uniqueId = self.message.uniqueId else { - Logger.error("Message is missing uniqueId.") - return - } - - guard self.uiDatabaseConnection.hasChange(forKey: uniqueId, - inCollection: TSInteraction.collection(), - in: notifications) else { - Logger.debug("No relevant changes.") - return - } - - do { - try updateMessageToLatest() - } catch DetailViewError.messageWasDeleted { - DispatchQueue.main.async { - self.delegate?.detailViewMessageWasDeleted(self) - } - return - } catch { - owsFailDebug("unexpected error: \(error)") - } - updateContent() - } - - private func string(for messageReceiptStatus: MessageReceiptStatus) -> String { - switch messageReceiptStatus { - case .uploading: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING", - comment: "Status label for messages which are uploading.") - case .sending: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENDING", - comment: "Status label for messages which are sending.") - case .sent: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENT", - comment: "Status label for messages which are sent.") - case .delivered: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_DELIVERED", - comment: "Status label for messages which are delivered.") - case .read: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_READ", - comment: "Status label for messages which are read.") - case .failed: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_FAILED", - comment: "Status label for messages which are failed.") - case .skipped: - return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SKIPPED", - comment: "Status label for messages which were skipped.") - } - } - - // MARK: - Message Bubble Layout - - private func updateMessageBubbleViewLayout() { - guard let messageBubbleView = messageBubbleView else { - return - } - guard let messageBubbleViewWidthLayoutConstraint = messageBubbleViewWidthLayoutConstraint else { - return - } - guard let messageBubbleViewHeightLayoutConstraint = messageBubbleViewHeightLayoutConstraint else { - return - } - - let messageBubbleSize = messageBubbleView.measureSize() - messageBubbleViewWidthLayoutConstraint.constant = messageBubbleSize.width - messageBubbleViewHeightLayoutConstraint.constant = messageBubbleSize.height - } - - // MARK: OWSMessageBubbleViewDelegate - - func didTapImageViewItem(_ viewItem: ConversationViewItem, attachmentStream: TSAttachmentStream, imageView: UIView) { - let mediaGallery = MediaGallery(thread: self.thread) - - mediaGallery.addDataSourceDelegate(self) - mediaGallery.presentDetailView(fromViewController: self, mediaAttachment: attachmentStream, replacingView: imageView) - } - - func didTapVideoViewItem(_ viewItem: ConversationViewItem, attachmentStream: TSAttachmentStream, imageView: UIView) { - let mediaGallery = MediaGallery(thread: self.thread) - - mediaGallery.addDataSourceDelegate(self) - mediaGallery.presentDetailView(fromViewController: self, mediaAttachment: attachmentStream, replacingView: imageView) - } - - var audioAttachmentPlayer: OWSAudioPlayer? - - func didTapAudioViewItem(_ viewItem: ConversationViewItem, attachmentStream: TSAttachmentStream) { - AssertIsOnMainThread() - - guard let mediaURL = attachmentStream.originalMediaURL else { - owsFailDebug("mediaURL was unexpectedly nil for attachment: \(attachmentStream)") - return - } - - guard FileManager.default.fileExists(atPath: mediaURL.path) else { - owsFailDebug("audio file missing at path: \(mediaURL)") - return - } - - if let audioAttachmentPlayer = self.audioAttachmentPlayer { - // Is this player associated with this media adapter? - if audioAttachmentPlayer.owner === viewItem { - // Tap to pause & unpause. - audioAttachmentPlayer.togglePlayState() - return - } - audioAttachmentPlayer.stop() - self.audioAttachmentPlayer = nil - } - - let audioAttachmentPlayer = OWSAudioPlayer(mediaUrl: mediaURL, audioBehavior: .audioMessagePlayback, delegate: viewItem) - self.audioAttachmentPlayer = audioAttachmentPlayer - - // Associate the player with this media adapter. - audioAttachmentPlayer.owner = viewItem - audioAttachmentPlayer.play() - } - - func didPanAudioViewItem(toCurrentTime currentTime: TimeInterval) { - // TODO: Implement - } - - func didTapTruncatedTextMessage(_ conversationItem: ConversationViewItem) { - guard let navigationController = self.navigationController else { - owsFailDebug("navigationController was unexpectedly nil") - return - } - - let viewController = LongTextViewController(viewItem: viewItem) - viewController.delegate = self - navigationController.pushViewController(viewController, animated: true) - } - - func didTapFailedIncomingAttachment(_ viewItem: ConversationViewItem) { - // no - op - } - - func didTapFailedOutgoingMessage(_ message: TSOutgoingMessage) { - // no - op - } - - func didTapConversationItem(_ viewItem: ConversationViewItem, quotedReply: OWSQuotedReplyModel) { - // no - op - } - - func didTapConversationItem(_ viewItem: ConversationViewItem, quotedReply: OWSQuotedReplyModel, failedThumbnailDownloadAttachmentPointer attachmentPointer: TSAttachmentPointer) { - // no - op - } - - func didTapConversationItem(_ viewItem: ConversationViewItem, linkPreview: OWSLinkPreview) { - guard let urlString = linkPreview.urlString else { - owsFailDebug("Missing url.") - return - } - guard let url = URL(string: urlString) else { - owsFailDebug("Invalid url: \(urlString).") - return - } - UIApplication.shared.openURL(url) - } - - @objc func didLongPressSent(sender: UIGestureRecognizer) { - guard sender.state == .began else { - return - } - let messageTimestamp = "\(message.timestamp)" - UIPasteboard.general.string = messageTimestamp - } - - var lastSearchedText: String? { - return nil - } - - // MediaGalleryDataSourceDelegate - - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) { - Logger.info("") - - guard (items.map({ $0.message }) == [self.message]) else { - // Should only be one message we can delete when viewing message details - owsFailDebug("Unexpectedly informed of irrelevant message deletion") - return - } - - self.wasDeleted = true - } - - func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) { - self.dismiss(animated: true) { - self.navigationController?.popViewController(animated: true) - } - } - - // MARK: - ContactShareViewHelperDelegate - - public func didCreateOrEditContact() { - updateContent() - self.dismiss(animated: true) - } -} - -extension MessageDetailViewController: LongTextViewDelegate { - func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) { - self.delegate?.detailViewMessageWasDeleted(self) - } -} diff --git a/Session/Conversations/OWSConversationSettingsViewController.h b/Session/Conversations/Settings/OWSConversationSettingsViewController.h similarity index 100% rename from Session/Conversations/OWSConversationSettingsViewController.h rename to Session/Conversations/Settings/OWSConversationSettingsViewController.h diff --git a/Session/Conversations/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m similarity index 89% rename from Session/Conversations/OWSConversationSettingsViewController.m rename to Session/Conversations/Settings/OWSConversationSettingsViewController.m index 21eae4b48..a4b81f6ea 100644 --- a/Session/Conversations/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -4,19 +4,14 @@ #import "OWSConversationSettingsViewController.h" #import "BlockListUIUtils.h" - - - #import "OWSBlockingManager.h" #import "OWSSoundSettingsViewController.h" - #import "Session-Swift.h" #import "UIFont+OWS.h" #import "UIView+OWS.h" #import #import #import - #import #import #import @@ -24,7 +19,6 @@ #import #import #import - #import #import #import @@ -35,29 +29,19 @@ NS_ASSUME_NONNULL_BEGIN -//#define SHOW_COLOR_PICKER +CGFloat kIconViewLength = 24; -const CGFloat kIconViewLength = 24; - -@interface OWSConversationSettingsViewController () < -#ifdef SHOW_COLOR_PICKER - ColorPickerDelegate, -#endif - OWSSheetViewControllerDelegate> +@interface OWSConversationSettingsViewController () @property (nonatomic) TSThread *thread; @property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; @property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; - @property (nonatomic) NSArray *disappearingMessagesDurations; @property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration; @property (nullable, nonatomic) MediaGallery *mediaGallery; @property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper; @property (nonatomic, readonly) UIImageView *avatarView; @property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; -#ifdef SHOW_COLOR_PICKER -@property (nonatomic) OWSColorPicker *colorPicker; -#endif @end @@ -242,11 +226,6 @@ const CGFloat kIconViewLength = 24; [[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId]; } -#ifdef SHOW_COLOR_PICKER - self.colorPicker = [[OWSColorPicker alloc] initWithThread:self.thread]; - self.colorPicker.delegate = self; -#endif - [self updateTableContents]; NSString *title; @@ -259,18 +238,6 @@ const CGFloat kIconViewLength = 24; self.tableView.backgroundColor = UIColor.clearColor; } -- (void)viewDidAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; - - if (self.showVerificationOnAppear) { - self.showVerificationOnAppear = NO; - if (self.isGroupThread) { - [self showGroupMembersView]; - } - } -} - - (void)updateTableContents { OWSTableContents *contents = [OWSTableContents new]; @@ -285,13 +252,7 @@ const CGFloat kIconViewLength = 24; OWSTableSection *mainSection = [OWSTableSection new]; mainSection.customHeaderView = [self mainSectionHeader]; - - if (self.isGroupThread) { - mainSection.customHeaderHeight = @(147.f); - } else { - BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1; - mainSection.customHeaderHeight = isSmallScreen ? @(201.f) : @(208.f); - } + mainSection.customHeaderHeight = @(UITableViewAutomaticDimension); if ([self.thread isKindOfClass:TSContactThread.class]) { [mainSection addItem:[OWSTableItem @@ -450,29 +411,6 @@ const CGFloat kIconViewLength = 24; actionBlock:nil]]; } } -#ifdef SHOW_COLOR_PICKER - [mainSection - addItem:[OWSTableItem - itemWithCustomCellBlock:^{ - OWSConversationSettingsViewController *strongSelf = weakSelf; - OWSCAssertDebug(strongSelf); - - ConversationColorName colorName = strongSelf.thread.conversationColorName; - UIColor *currentColor = - [OWSConversationColor conversationColorOrDefaultForColorName:colorName].themeColor; - NSString *title = NSLocalizedString(@"CONVERSATION_SETTINGS_CONVERSATION_COLOR", - @"Label for table cell which leads to picking a new conversation color"); - return [strongSelf - cellWithName:title - iconName:@"ic_color_palette" - disclosureIconColor:currentColor - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME( - OWSConversationSettingsViewController, @"conversation_color")]; - } - actionBlock:^{ - [weakSelf showColorPicker]; - }]]; -#endif [contents addSection:mainSection]; @@ -637,9 +575,6 @@ const CGFloat kIconViewLength = 24; // Block Conversation section. if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) { - mainSection.footerTitle = NSLocalizedString( - @"BLOCK_USER_BEHAVIOR_EXPLANATION", @"An explanation of the consequences of blocking another user."); - [mainSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ OWSConversationSettingsViewController *strongSelf = weakSelf; @@ -727,48 +662,18 @@ const CGFloat kIconViewLength = 24; return cell; } -static CGRect oldframe; - --(void)showProfilePicture:(UITapGestureRecognizer *)tapGesture +- (void)showProfilePicture:(UITapGestureRecognizer *)tapGesture { LKProfilePictureView *profilePictureView = (LKProfilePictureView *)tapGesture.view; UIImage *image = [profilePictureView getProfilePicture]; if (image == nil) { return; } - - UIWindow *window = [UIApplication sharedApplication].keyWindow; - UIView *backgroundView = [[UIView alloc]initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height)]; - oldframe = [profilePictureView convertRect:profilePictureView.bounds toView:window]; - backgroundView.backgroundColor = [UIColor blackColor]; - backgroundView.alpha = 0; - UIImageView *imageView = [[UIImageView alloc] initWithFrame:oldframe]; - imageView.image = image; - imageView.tag = 1; - imageView.layer.cornerRadius = [UIScreen mainScreen].bounds.size.width / 2; - imageView.layer.masksToBounds = true; - [backgroundView addSubview:imageView]; - [window addSubview:backgroundView]; - - UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(hideImage:)]; - [backgroundView addGestureRecognizer: tap]; - - [UIView animateWithDuration:0.25 animations:^{ - imageView.frame = CGRectMake(0,([UIScreen mainScreen].bounds.size.height - oldframe.size.height * [UIScreen mainScreen].bounds.size.width / oldframe.size.width) / 2, [UIScreen mainScreen].bounds.size.width, oldframe.size.height * [UIScreen mainScreen].bounds.size.width / oldframe.size.width); - backgroundView.alpha = 1; - } completion:nil]; + NSString *title = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous"; + SNProfilePictureVC *profilePictureVC = [[SNProfilePictureVC alloc] initWithImage:image title:title]; + UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:profilePictureVC]; + navController.modalPresentationStyle = UIModalPresentationFullScreen; + [self presentViewController:navController animated:YES completion:nil]; } --(void)hideImage:(UITapGestureRecognizer *)tap{ - UIView *backgroundView = tap.view; - UIImageView *imageView = (UIImageView *)[tap.view viewWithTag:1]; - [UIView animateWithDuration:0.25 animations:^{ - imageView.frame = oldframe; - backgroundView.alpha = 0; - } completion:^(BOOL finished) { - [backgroundView removeFromSuperview]; - }]; -} - - - (UIView *)mainSectionHeader { UITapGestureRecognizer *profilePictureTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showProfilePicture:)]; @@ -872,22 +777,6 @@ static CGRect oldframe; #pragma mark - Actions -- (void)showShareProfileAlert -{ - [self.profileManager presentAddThreadToProfileWhitelist:self.thread - fromViewController:self - success:^{ - [self updateTableContents]; - }]; -} - -- (void)showGroupMembersView -{ - TSGroupThread *thread = (TSGroupThread *)self.thread; - LKGroupMembersVC *groupMembersVC = [[LKGroupMembersVC alloc] initWithThread:thread]; - [self.navigationController pushViewController:groupMembersVC animated:YES]; -} - - (void)editGroup { LKEditClosedGroupVC *editClosedGroupVC = [[LKEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId]; @@ -1206,44 +1095,6 @@ static CGRect oldframe; } } -#pragma mark - ColorPickerDelegate - -#ifdef SHOW_COLOR_PICKER - -- (void)showColorPicker -{ - OWSSheetViewController *sheetViewController = self.colorPicker.sheetViewController; - sheetViewController.delegate = self; - - [self presentViewController:sheetViewController - animated:YES - completion:^() { - OWSLogInfo(@"presented sheet view"); - }]; -} - -- (void)colorPicker:(OWSColorPicker *)colorPicker - didPickConversationColor:(OWSConversationColor *_Nonnull)conversationColor -{ - OWSLogDebug(@"picked color: %@", conversationColor.name); - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self.thread updateConversationColorName:conversationColor.name transaction:transaction]; - }]; - - [self.contactsManager.avatarCache removeAllImages]; - [self updateTableContents]; - [self.conversationSettingsViewDelegate conversationColorWasUpdated]; - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ConversationConfigurationSyncOperation *operation = - [[ConversationConfigurationSyncOperation alloc] initWithThread:self.thread]; - OWSAssertDebug(operation.isReady); - [operation start]; - }); -} - -#endif - #pragma mark - OWSSheetViewController - (void)sheetViewControllerRequestedDismiss:(OWSSheetViewController *)sheetViewController diff --git a/Session/Conversations/OWSConversationSettingsViewDelegate.h b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h similarity index 93% rename from Session/Conversations/OWSConversationSettingsViewDelegate.h rename to Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h index 06b8e515c..6e421cfa4 100644 --- a/Session/Conversations/OWSConversationSettingsViewDelegate.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h @@ -9,7 +9,6 @@ NS_ASSUME_NONNULL_BEGIN @protocol OWSConversationSettingsViewDelegate -- (void)conversationColorWasUpdated; - (void)groupWasUpdated:(TSGroupModel *)groupModel; - (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; diff --git a/Session/Conversations/Views & Cells/OWSMessageTimerView.h b/Session/Conversations/Settings/OWSMessageTimerView.h similarity index 100% rename from Session/Conversations/Views & Cells/OWSMessageTimerView.h rename to Session/Conversations/Settings/OWSMessageTimerView.h diff --git a/Session/Conversations/Views & Cells/OWSMessageTimerView.m b/Session/Conversations/Settings/OWSMessageTimerView.m similarity index 99% rename from Session/Conversations/Views & Cells/OWSMessageTimerView.m rename to Session/Conversations/Settings/OWSMessageTimerView.m index 1b4bd621d..d1fdd08a7 100644 --- a/Session/Conversations/Views & Cells/OWSMessageTimerView.m +++ b/Session/Conversations/Settings/OWSMessageTimerView.m @@ -3,7 +3,6 @@ // #import "OWSMessageTimerView.h" -#import "ConversationViewController.h" #import "OWSMath.h" #import "UIColor+OWS.h" #import "UIView+OWS.h" diff --git a/Session/Conversations/Settings/ProfilePictureVC.swift b/Session/Conversations/Settings/ProfilePictureVC.swift new file mode 100644 index 000000000..54ebf3ebf --- /dev/null +++ b/Session/Conversations/Settings/ProfilePictureVC.swift @@ -0,0 +1,48 @@ + +@objc(SNProfilePictureVC) +final class ProfilePictureVC : BaseVC { + private let image: UIImage + private let snTitle: String + + @objc init(image: UIImage, title: String) { + self.image = image + self.snTitle = title + super.init(nibName: nil, bundle: nil) + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(image:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(coder:) instead.") + } + + override func viewDidLoad() { + view.backgroundColor = .clear + setUpGradientBackground() + setUpNavBarStyle() + setNavBarTitle(snTitle) + // Close button + let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) + closeButton.tintColor = Colors.text + navigationItem.leftBarButtonItem = closeButton + // Image view + let imageView = UIImageView(image: image) + let size = UIScreen.main.bounds.width - 2 * Values.largeSpacing + imageView.set(.width, to: size) + imageView.set(.height, to: size) + imageView.layer.cornerRadius = size / 2 + imageView.layer.masksToBounds = true + view.addSubview(imageView) + imageView.center(in: view) + // Gesture recognizer + let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close)) + swipeGestureRecognizer.direction = .down + view.addGestureRecognizer(swipeGestureRecognizer) + } + + @objc private func close() { + presentingViewController?.dismiss(animated: true, completion: nil) + } +} diff --git a/Session/Conversations/Views & Cells/AttachmentUploadView.h b/Session/Conversations/Views & Cells/AttachmentUploadView.h deleted file mode 100644 index 76c30e613..000000000 --- a/Session/Conversations/Views & Cells/AttachmentUploadView.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class TSAttachmentStream; - -// This entity is used to display upload progress for outgoing -// attachments in conversation view cells. -// -// During attachment uploads we want to: -// -// * Dim the media view using a mask layer. -// * Show and update a progress bar. -// * Disable any media view controls using a callback. -@interface AttachmentUploadView : UIView - -- (instancetype)initWithAttachment:(TSAttachmentStream *)attachment; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/AttachmentUploadView.m b/Session/Conversations/Views & Cells/AttachmentUploadView.m deleted file mode 100644 index 1be1ec462..000000000 --- a/Session/Conversations/Views & Cells/AttachmentUploadView.m +++ /dev/null @@ -1,118 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "AttachmentUploadView.h" -#import "OWSBezierPathView.h" -#import "OWSProgressView.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface AttachmentUploadView () - -@property (nonatomic) TSAttachmentStream *attachment; - -@property (nonatomic) OWSProgressView *progressView; - -@property (nonatomic) UILabel *progressLabel; - -@property (nonatomic) BOOL isAttachmentReady; - -@property (nonatomic) CGFloat lastProgress; - -@end - -#pragma mark - - -@implementation AttachmentUploadView - -- (instancetype)initWithAttachment:(TSAttachmentStream *)attachment -{ - self = [super init]; - - if (self) { - OWSAssertDebug(attachment); - - self.attachment = attachment; - - [self createContents]; - - _isAttachmentReady = self.attachment.isUploaded; - - [self ensureViewState]; - } - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)createContents -{ - // The progress view is white. It will only be shown - // while the mask layer is visible, so it will show up - // even against all-white attachments. - _progressView = [OWSProgressView new]; - self.progressView.color = [UIColor whiteColor]; - [self.progressView autoSetDimension:ALDimensionWidth toSize:80.f]; - [self.progressView autoSetDimension:ALDimensionHeight toSize:6.f]; - - self.progressLabel = [UILabel new]; - self.progressLabel.text = NSLocalizedString( - @"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING", @"Status label for messages which are uploading.") - .localizedUppercaseString; - self.progressLabel.textColor = UIColor.whiteColor; - self.progressLabel.font = [UIFont ows_dynamicTypeCaption1Font]; - self.progressLabel.textAlignment = NSTextAlignmentCenter; - - UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ - self.progressView, - self.progressLabel, - ]]; - stackView.axis = UILayoutConstraintAxisVertical; - stackView.spacing = 4; - stackView.layoutMargins = UIEdgeInsetsMake(4, 4, 4, 4); - stackView.layoutMarginsRelativeArrangement = YES; - [self addSubview:stackView]; - [stackView autoCenterInSuperview]; - [stackView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual]; - [stackView autoPinEdgeToSuperviewMargin:ALEdgeBottom relation:NSLayoutRelationGreaterThanOrEqual]; - [stackView autoPinEdgeToSuperviewMargin:ALEdgeLeading relation:NSLayoutRelationGreaterThanOrEqual]; - [stackView autoPinEdgeToSuperviewMargin:ALEdgeTrailing relation:NSLayoutRelationGreaterThanOrEqual]; -} - -- (void)setIsAttachmentReady:(BOOL)isAttachmentReady -{ - if (_isAttachmentReady == isAttachmentReady) { - return; - } - - _isAttachmentReady = isAttachmentReady; - - [self ensureViewState]; -} - -- (void)setLastProgress:(CGFloat)lastProgress -{ - _lastProgress = lastProgress; - - [self ensureViewState]; -} - -- (void)ensureViewState -{ - BOOL isUploading = !self.isAttachmentReady && self.lastProgress != 0; - self.backgroundColor = (isUploading ? [UIColor colorWithWhite:0.f alpha:0.2f] : nil); - self.progressView.hidden = !isUploading; - self.progressLabel.hidden = !isUploading; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/ConversationViewCell.h b/Session/Conversations/Views & Cells/ConversationViewCell.h deleted file mode 100644 index 8d7c7fd17..000000000 --- a/Session/Conversations/Views & Cells/ConversationViewCell.h +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; -@class ConversationViewCell; -@class OWSContactOffersInteraction; -@class OWSContactsManager; -@class TSAttachmentStream; -@class TSCall; -@class TSErrorMessage; -@class TSInteraction; -@class TSInvalidIdentityKeyErrorMessage; -@class TSMessage; -@class TSOutgoingMessage; -@class TSQuotedMessage; - -@protocol ConversationViewItem; - -@protocol ConversationViewCellDelegate - -- (void)conversationCell:(ConversationViewCell *)cell - shouldAllowReply:(BOOL)shouldAllowReply - didLongpressTextViewItem:(id)viewItem; -- (void)conversationCell:(ConversationViewCell *)cell - shouldAllowReply:(BOOL)shouldAllowReply - didLongpressMediaViewItem:(id)viewItem; -- (void)conversationCell:(ConversationViewCell *)cell - shouldAllowReply:(BOOL)shouldAllowReply - didLongpressQuoteViewItem:(id)viewItem; -- (void)conversationCell:(ConversationViewCell *)cell - didLongpressSystemMessageViewItem:(id)viewItem; - -#pragma mark - System Cell - -- (void)tappedCorruptedMessage:(TSErrorMessage *)message; -- (void)resendGroupUpdateForErrorMessage:(TSErrorMessage *)message; -- (void)showConversationSettings; - -#pragma mark - Caching - -- (NSCache *)cellMediaCache; - -#pragma mark - Messages - -- (void)didTapFailedOutgoingMessage:(TSOutgoingMessage *)message; - -@end - -#pragma mark - - -// TODO: Consider making this a protocol. -@interface ConversationViewCell : UICollectionViewCell - -@property (nonatomic, nullable, weak) id delegate; - -@property (nonatomic, nullable) id viewItem; - -// Cells are prefetched but expensive cells (e.g. media) should only load -// when visible and unload when no longer visible. Non-visible cells can -// cache their contents on their ConversationViewItem, but that cache may -// be evacuated before the cell becomes visible again. -// -// ConversationViewController also uses this property to evacuate the cell's -// meda views when: -// -// * App enters background. -// * Users enters another view (e.g. conversation settings view, call screen, etc.). -@property (nonatomic) BOOL isCellVisible; - -@property (nonatomic, nullable) ConversationStyle *conversationStyle; - -- (void)loadForDisplay; - -- (CGSize)cellSize; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/ConversationViewCell.m b/Session/Conversations/Views & Cells/ConversationViewCell.m deleted file mode 100644 index 9b7fd5fce..000000000 --- a/Session/Conversations/Views & Cells/ConversationViewCell.m +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewCell.h" -#import "ConversationViewItem.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation ConversationViewCell - -- (void)prepareForReuse -{ - [super prepareForReuse]; - - self.viewItem = nil; - self.delegate = nil; - self.isCellVisible = NO; - self.conversationStyle = nil; -} - -- (void)loadForDisplay -{ - OWSAbstractMethod(); -} - -- (CGSize)cellSize -{ - OWSAbstractMethod(); - - return CGSizeZero; -} - -// For perf reasons, skip the default implementation which is only relevant for self-sizing cells. -- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes: - (UICollectionViewLayoutAttributes *)layoutAttributes -{ - return layoutAttributes; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/LinkPreviewView.swift b/Session/Conversations/Views & Cells/LinkPreviewView.swift deleted file mode 100644 index 1b765fb0f..000000000 --- a/Session/Conversations/Views & Cells/LinkPreviewView.swift +++ /dev/null @@ -1,844 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -extension CGPoint { - public func offsetBy(dx: CGFloat) -> CGPoint { - return CGPoint(x: x + dx, y: y) - } - - public func offsetBy(dy: CGFloat) -> CGPoint { - return CGPoint(x: x, y: y + dy) - } -} - -// MARK: - - -@objc -public enum LinkPreviewImageState: Int { - case none - case loading - case loaded - case invalid -} - -// MARK: - - -@objc -public protocol LinkPreviewState { - func isLoaded() -> Bool - func urlString() -> String? - func displayDomain() -> String? - func title() -> String? - func imageState() -> LinkPreviewImageState - func image() -> UIImage? -} - -// MARK: - - -@objc -public class LinkPreviewLoading: NSObject, LinkPreviewState { - - override init() { - } - - public func isLoaded() -> Bool { - return false - } - - public func urlString() -> String? { - return nil - } - - public func displayDomain() -> String? { - return nil - } - - public func title() -> String? { - return nil - } - - public func imageState() -> LinkPreviewImageState { - return .none - } - - public func image() -> UIImage? { - return nil - } -} - -// MARK: - - -@objc -public class LinkPreviewDraft: NSObject, LinkPreviewState { - private let linkPreviewDraft: OWSLinkPreviewDraft - - @objc - public required init(linkPreviewDraft: OWSLinkPreviewDraft) { - self.linkPreviewDraft = linkPreviewDraft - } - - public func isLoaded() -> Bool { - return true - } - - public func urlString() -> String? { - return linkPreviewDraft.urlString - } - - public func displayDomain() -> String? { - guard let displayDomain = linkPreviewDraft.displayDomain() else { - owsFailDebug("Missing display domain") - return nil - } - return displayDomain - } - - public func title() -> String? { - guard let value = linkPreviewDraft.title, - value.count > 0 else { - return nil - } - return value - } - - public func imageState() -> LinkPreviewImageState { - if linkPreviewDraft.jpegImageData != nil { - return .loaded - } else { - return .none - } - } - - public func image() -> UIImage? { - assert(imageState() == .loaded) - - guard let jpegImageData = linkPreviewDraft.jpegImageData else { - return nil - } - guard let image = UIImage(data: jpegImageData) else { - owsFailDebug("Could not load image: \(jpegImageData.count)") - return nil - } - return image - } -} - -// MARK: - - -@objc -public class LinkPreviewSent: NSObject, LinkPreviewState { - private let linkPreview: OWSLinkPreview - private let imageAttachment: TSAttachment? - - @objc public let conversationStyle: ConversationStyle - - @objc - public var imageSize: CGSize { - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - return CGSize.zero - } - return attachmentStream.imageSize() - } - - @objc - public required init(linkPreview: OWSLinkPreview, - imageAttachment: TSAttachment?, - conversationStyle: ConversationStyle) { - self.linkPreview = linkPreview - self.imageAttachment = imageAttachment - self.conversationStyle = conversationStyle - } - - public func isLoaded() -> Bool { - return true - } - - public func urlString() -> String? { - guard let urlString = linkPreview.urlString else { - owsFailDebug("Missing url") - return nil - } - return urlString - } - - public func displayDomain() -> String? { - guard let displayDomain = linkPreview.displayDomain() else { - Logger.error("Missing display domain") - return nil - } - return displayDomain - } - - public func title() -> String? { - guard let value = linkPreview.title, - value.count > 0 else { - return nil - } - return value - } - - public func imageState() -> LinkPreviewImageState { - guard linkPreview.imageAttachmentId != nil else { - return .none - } - guard let imageAttachment = imageAttachment else { - owsFailDebug("Missing imageAttachment.") - return .none - } - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - return .loading - } - guard attachmentStream.isImage, - attachmentStream.isValidImage else { - return .invalid - } - return .loaded - } - - public func image() -> UIImage? { - assert(imageState() == .loaded) - - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - owsFailDebug("Could not load image.") - return nil - } - guard attachmentStream.isImage, - attachmentStream.isValidImage else { - return nil - } - guard let imageFilepath = attachmentStream.originalFilePath else { - owsFailDebug("Attachment is missing file path.") - return nil - } - guard let image = UIImage(contentsOfFile: imageFilepath) else { - owsFailDebug("Could not load image: \(imageFilepath)") - return nil - } - return image - } -} - -// MARK: - - -@objc -public protocol LinkPreviewViewDraftDelegate { - func linkPreviewCanCancel() -> Bool - func linkPreviewDidCancel() -} - -// MARK: - - -@objc -public class LinkPreviewImageView: UIImageView { - private let maskLayer = CAShapeLayer() - - private let hasAsymmetricalRounding: Bool - - @objc - public init(hasAsymmetricalRounding: Bool) { - self.hasAsymmetricalRounding = hasAsymmetricalRounding - - super.init(frame: .zero) - - self.layer.mask = maskLayer - } - - public required init?(coder aDecoder: NSCoder) { - self.hasAsymmetricalRounding = false - - super.init(coder: aDecoder) - } - - public override var bounds: CGRect { - didSet { - updateMaskLayer() - } - } - - public override var frame: CGRect { - didSet { - updateMaskLayer() - } - } - - public override var center: CGPoint { - didSet { - updateMaskLayer() - } - } - - private func updateMaskLayer() { - let layerBounds = self.bounds - - // One of the corners has assymetrical rounding to match the input toolbar border. - // This is somewhat inconvenient. - let upperLeft = CGPoint(x: 0, y: 0) - let upperRight = CGPoint(x: layerBounds.size.width, y: 0) - let lowerRight = CGPoint(x: layerBounds.size.width, y: layerBounds.size.height) - let lowerLeft = CGPoint(x: 0, y: layerBounds.size.height) - - let bigRounding: CGFloat = 14 - let smallRounding: CGFloat = 4 - - let upperLeftRounding: CGFloat - let upperRightRounding: CGFloat - if hasAsymmetricalRounding { - upperLeftRounding = CurrentAppContext().isRTL ? smallRounding : bigRounding - upperRightRounding = CurrentAppContext().isRTL ? bigRounding : smallRounding - } else { - upperLeftRounding = smallRounding - upperRightRounding = smallRounding - } - let lowerRightRounding = smallRounding - let lowerLeftRounding = smallRounding - - let path = UIBezierPath() - - // It's sufficient to "draw" the rounded corners and not the edges that connect them. - path.addArc(withCenter: upperLeft.offsetBy(dx: +upperLeftRounding).offsetBy(dy: +upperLeftRounding), - radius: upperLeftRounding, - startAngle: CGFloat.pi * 1.0, - endAngle: CGFloat.pi * 1.5, - clockwise: true) - - path.addArc(withCenter: upperRight.offsetBy(dx: -upperRightRounding).offsetBy(dy: +upperRightRounding), - radius: upperRightRounding, - startAngle: CGFloat.pi * 1.5, - endAngle: CGFloat.pi * 0.0, - clockwise: true) - - path.addArc(withCenter: lowerRight.offsetBy(dx: -lowerRightRounding).offsetBy(dy: -lowerRightRounding), - radius: lowerRightRounding, - startAngle: CGFloat.pi * 0.0, - endAngle: CGFloat.pi * 0.5, - clockwise: true) - - path.addArc(withCenter: lowerLeft.offsetBy(dx: +lowerLeftRounding).offsetBy(dy: -lowerLeftRounding), - radius: lowerLeftRounding, - startAngle: CGFloat.pi * 0.5, - endAngle: CGFloat.pi * 1.0, - clockwise: true) - - maskLayer.path = path.cgPath - } -} - -// MARK: - - -@objc -public class LinkPreviewView: UIStackView { - private weak var draftDelegate: LinkPreviewViewDraftDelegate? - - @objc - public var state: LinkPreviewState? { - didSet { - AssertIsOnMainThread() - assert(state == nil || oldValue == nil) - - updateContents() - } - } - - @objc - public var hasAsymmetricalRounding: Bool = false { - didSet { - AssertIsOnMainThread() - - if hasAsymmetricalRounding != oldValue { - updateContents() - } - } - } - - @available(*, unavailable, message:"use other constructor instead.") - required init(coder aDecoder: NSCoder) { - notImplemented() - } - - @available(*, unavailable, message:"use other constructor instead.") - override init(frame: CGRect) { - notImplemented() - } - - private var cancelButton: UIButton? - private weak var heroImageView: UIView? - private weak var sentBodyView: UIView? - private var layoutConstraints = [NSLayoutConstraint]() - - @objc - public init(draftDelegate: LinkPreviewViewDraftDelegate?) { - self.draftDelegate = draftDelegate - - super.init(frame: .zero) - - if let draftDelegate = draftDelegate, - draftDelegate.linkPreviewCanCancel() { - self.isUserInteractionEnabled = true - self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped))) - } - } - - private var isDraft: Bool { - return draftDelegate != nil - } - - private func resetContents() { - for subview in subviews { - subview.removeFromSuperview() - } - self.axis = .horizontal - self.alignment = .center - self.distribution = .fill - self.spacing = 0 - self.isLayoutMarginsRelativeArrangement = false - self.layoutMargins = .zero - - cancelButton = nil - heroImageView = nil - sentBodyView = nil - - NSLayoutConstraint.deactivate(layoutConstraints) - layoutConstraints = [] - } - - private func updateContents() { - resetContents() - - guard let state = state else { - return - } - - guard isDraft else { - createSentContents() - return - } - guard state.isLoaded() else { - createDraftLoadingContents() - return - } - createDraftContents(state: state) - } - - private func createSentContents() { - guard let state = state as? LinkPreviewSent else { - owsFailDebug("Invalid state") - return - } - - self.addBackgroundView(withBackgroundColor: Theme.backgroundColor) - - if let imageView = createImageView(state: state) { - if sentIsHero(state: state) { - createHeroSentContents(state: state, - imageView: imageView) - } else { - createNonHeroSentContents(state: state, - imageView: imageView) - } - } else { - createNonHeroSentContents(state: state, - imageView: nil) - } - } - - private func sentHeroImageSize(state: LinkPreviewSent) -> CGSize { - let maxMessageWidth = state.conversationStyle.maxMessageWidth - let imageSize = state.imageSize - let minImageHeight: CGFloat = maxMessageWidth * 0.5 - let maxImageHeight: CGFloat = maxMessageWidth - let rawImageHeight = maxMessageWidth * imageSize.height / imageSize.width - let imageHeight: CGFloat = min(maxImageHeight, max(minImageHeight, rawImageHeight)) - return CGSizeCeil(CGSize(width: maxMessageWidth, height: imageHeight)) - } - - private func createHeroSentContents(state: LinkPreviewSent, - imageView: UIImageView) { - self.layoutMargins = .zero - self.axis = .vertical - self.alignment = .fill - - let heroImageSize = sentHeroImageSize(state: state) - imageView.autoSetDimensions(to: heroImageSize) - imageView.contentMode = .scaleAspectFill - imageView.setContentHuggingHigh() - imageView.setCompressionResistanceHigh() - imageView.clipsToBounds = true - // TODO: Cropping, stroke. - addArrangedSubview(imageView) - - let textStack = createSentTextStack(state: state) - textStack.isLayoutMarginsRelativeArrangement = true - textStack.layoutMargins = UIEdgeInsets(top: sentHeroVMargin, left: sentHeroHMargin, bottom: sentHeroVMargin, right: sentHeroHMargin) - addArrangedSubview(textStack) - - heroImageView = imageView - sentBodyView = textStack - } - - private func createNonHeroSentContents(state: LinkPreviewSent, - imageView: UIImageView?) { - self.layoutMargins = .zero - self.axis = .horizontal - self.isLayoutMarginsRelativeArrangement = true - self.layoutMargins = UIEdgeInsets(top: sentNonHeroVMargin, left: sentNonHeroHMargin, bottom: sentNonHeroVMargin, right: sentNonHeroHMargin) - self.spacing = sentNonHeroHSpacing - - if let imageView = imageView { - imageView.autoSetDimensions(to: CGSize(width: sentNonHeroImageSize, height: sentNonHeroImageSize)) - imageView.contentMode = .scaleAspectFill - imageView.setContentHuggingHigh() - imageView.setCompressionResistanceHigh() - imageView.clipsToBounds = true - // TODO: Cropping, stroke. - addArrangedSubview(imageView) - } - - let textStack = createSentTextStack(state: state) - addArrangedSubview(textStack) - - sentBodyView = self - } - - private func createSentTextStack(state: LinkPreviewSent) -> UIStackView { - let textStack = UIStackView() - textStack.axis = .vertical - textStack.spacing = sentVSpacing - - if let titleLabel = sentTitleLabel(state: state) { - textStack.addArrangedSubview(titleLabel) - } - let domainLabel = sentDomainLabel(state: state) - textStack.addArrangedSubview(domainLabel) - - return textStack - } - - private let sentMinimumHeroSize: CGFloat = 200 - - private let sentTitleFontSizePoints: CGFloat = Values.mediumFontSize - private let sentDomainFontSizePoints: CGFloat = Values.verySmallFontSize - private let sentVSpacing: CGFloat = 4 - - // The "sent message" mode has two submodes: "hero" and "non-hero". - private let sentNonHeroHMargin: CGFloat = 12 - private let sentNonHeroVMargin: CGFloat = 12 - private let sentNonHeroImageSize: CGFloat = 72 - private let sentNonHeroHSpacing: CGFloat = 8 - - private let sentHeroHMargin: CGFloat = 12 - private let sentHeroVMargin: CGFloat = 12 - - private func sentIsHero(state: LinkPreviewSent) -> Bool { - let imageSize = state.imageSize - return imageSize.width >= sentMinimumHeroSize && imageSize.height >= sentMinimumHeroSize - } - - private let sentTitleLineCount: Int = 2 - - private func sentTitleLabel(state: LinkPreviewState) -> UILabel? { - guard let text = state.title() else { - return nil - } - let label = UILabel() - label.text = text - label.font = UIFont.systemFont(ofSize: sentTitleFontSizePoints).ows_mediumWeight() - label.textColor = Theme.primaryColor - label.numberOfLines = sentTitleLineCount - label.lineBreakMode = .byWordWrapping - return label - } - - private func sentDomainLabel(state: LinkPreviewState) -> UILabel { - let label = UILabel() - if let displayDomain = state.displayDomain(), - displayDomain.count > 0 { - label.text = displayDomain - } else { - label.text = NSLocalizedString("LINK_PREVIEW_UNKNOWN_DOMAIN", comment: "Label for link previews with an unknown host.") - } - label.font = UIFont.systemFont(ofSize: sentDomainFontSizePoints) - label.textColor = Theme.secondaryColor - return label - } - - private let draftHeight: CGFloat = 72 - private let draftMarginTop: CGFloat = 6 - - private func createDraftContents(state: LinkPreviewState) { - self.axis = .horizontal - self.alignment = .fill - self.distribution = .fill - self.spacing = 8 - self.isLayoutMarginsRelativeArrangement = true - - self.layoutConstraints.append(self.autoSetDimension(.height, toSize: draftHeight + draftMarginTop)) - - // Image - - let draftImageView = createDraftImageView(state: state) - if let imageView = draftImageView { - imageView.contentMode = .scaleAspectFill - imageView.autoPinToSquareAspectRatio() - let imageSize = draftHeight - imageView.autoSetDimensions(to: CGSize(width: imageSize, height: imageSize)) - imageView.setContentHuggingHigh() - imageView.setCompressionResistanceHigh() - imageView.clipsToBounds = true - addArrangedSubview(imageView) - } - - let hasImage = draftImageView != nil - let hMarginLeading: CGFloat = hasImage ? 6 : 12 - let hMarginTrailing: CGFloat = 12 - self.layoutMargins = UIEdgeInsets(top: draftMarginTop, - leading: hMarginLeading, - bottom: 0, - trailing: hMarginTrailing) - - // Right - - let rightStack = UIStackView() - rightStack.axis = .horizontal - rightStack.alignment = .fill - rightStack.distribution = .equalSpacing - rightStack.spacing = 8 - rightStack.setContentHuggingHorizontalLow() - rightStack.setCompressionResistanceHorizontalLow() - addArrangedSubview(rightStack) - - // Text - - let textStack = UIStackView() - textStack.axis = .vertical - textStack.alignment = .leading - textStack.spacing = 2 - textStack.setContentHuggingHorizontalLow() - textStack.setCompressionResistanceHorizontalLow() - - if let title = state.title(), - title.count > 0 { - let label = UILabel() - label.text = title - label.textColor = Colors.text - label.font = .systemFont(ofSize: Values.mediumFontSize) - textStack.addArrangedSubview(label) - } - if let displayDomain = state.displayDomain(), - displayDomain.count > 0 { - let label = UILabel() - label.text = displayDomain - label.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) - label.font = .systemFont(ofSize: Values.verySmallFontSize) - textStack.addArrangedSubview(label) - } - - let textWrapper = UIStackView(arrangedSubviews: [textStack]) - textWrapper.axis = .horizontal - textWrapper.alignment = .center - textWrapper.setContentHuggingHorizontalLow() - textWrapper.setCompressionResistanceHorizontalLow() - - rightStack.addArrangedSubview(textWrapper) - - // Cancel - - let cancelStack = UIStackView() - cancelStack.axis = .horizontal - cancelStack.alignment = .top - cancelStack.setContentHuggingHigh() - cancelStack.setCompressionResistanceHigh() - - let cancelImage = UIImage(named: "compose-cancel")?.withRenderingMode(.alwaysTemplate) - let cancelButton = UIButton(type: .custom) - cancelButton.setImage(cancelImage, for: .normal) - cancelButton.addTarget(self, action: #selector(didTapCancel(sender:)), for: .touchUpInside) - self.cancelButton = cancelButton - cancelButton.tintColor = Theme.secondaryColor - cancelButton.setContentHuggingHigh() - cancelButton.setCompressionResistanceHigh() - cancelButton.isHidden = false - cancelStack.addArrangedSubview(cancelButton) - - rightStack.addArrangedSubview(cancelStack) - - // Stroke - let strokeView = UIView() - strokeView.backgroundColor = Theme.secondaryColor - rightStack.addSubview(strokeView) - strokeView.autoPinWidthToSuperview() - strokeView.autoPinEdge(toSuperviewEdge: .bottom) - strokeView.autoSetDimension(.height, toSize: CGHairlineWidth()) - } - - private func createImageView(state: LinkPreviewState) -> UIImageView? { - guard state.isLoaded() else { - owsFailDebug("State not loaded.") - return nil - } - - guard state.imageState() == .loaded else { - return nil - } - guard let image = state.image() else { - owsFailDebug("Could not load image.") - return nil - } - let imageView = UIImageView() - imageView.image = image - return imageView - } - - private func createDraftImageView(state: LinkPreviewState) -> UIImageView? { - guard state.isLoaded() else { - owsFailDebug("State not loaded.") - return nil - } - - guard state.imageState() == .loaded else { - return nil - } - guard let image = state.image() else { - owsFailDebug("Could not load image.") - return nil - } - let imageView = LinkPreviewImageView(hasAsymmetricalRounding: self.hasAsymmetricalRounding) - imageView.image = image - return imageView - } - - private func createDraftLoadingContents() { - self.axis = .vertical - self.alignment = .center - - self.layoutConstraints.append(self.autoSetDimension(.height, toSize: draftHeight + draftMarginTop)) - - let activityIndicatorStyle: UIActivityIndicatorView.Style = (Theme.isDarkThemeEnabled - ? .white - : .gray) - let activityIndicator = UIActivityIndicatorView(style: activityIndicatorStyle) - activityIndicator.startAnimating() - addArrangedSubview(activityIndicator) - let activityIndicatorSize: CGFloat = 25 - activityIndicator.autoSetDimensions(to: CGSize(width: activityIndicatorSize, height: activityIndicatorSize)) - - // Stroke - let strokeView = UIView() - strokeView.backgroundColor = Theme.secondaryColor - self.addSubview(strokeView) - strokeView.autoPinWidthToSuperview(withMargin: 12) - strokeView.autoPinEdge(toSuperviewEdge: .bottom) - strokeView.autoSetDimension(.height, toSize: CGHairlineWidth()) - } - - // MARK: Events - - @objc func wasTapped(sender: UIGestureRecognizer) { - guard sender.state == .recognized else { - return - } - if let cancelButton = cancelButton { - let cancelLocation = sender.location(in: cancelButton) - // Permissive hot area to make it very easy to cancel the link preview. - let hotAreaInset: CGFloat = -20 - let cancelButtonHotArea = cancelButton.bounds.insetBy(dx: hotAreaInset, dy: hotAreaInset) - if cancelButtonHotArea.contains(cancelLocation) { - self.draftDelegate?.linkPreviewDidCancel() - return - } - } - } - - // MARK: Measurement - - @objc - public func measure(withSentState state: LinkPreviewSent) -> CGSize { - switch state.imageState() { - case .loaded: - if sentIsHero(state: state) { - return measureSentHero(state: state) - } else { - return measureSentNonHero(state: state, hasImage: true) - } - default: - return measureSentNonHero(state: state, hasImage: false) - } - } - - private func measureSentHero(state: LinkPreviewSent) -> CGSize { - let maxMessageWidth = state.conversationStyle.maxMessageWidth - var messageHeight: CGFloat = 0 - - let heroImageSize = sentHeroImageSize(state: state) - messageHeight += heroImageSize.height - - let textStackSize = sentTextStackSize(state: state, maxWidth: maxMessageWidth - 2 * sentHeroHMargin) - messageHeight += textStackSize.height + 2 * sentHeroVMargin - - return CGSizeCeil(CGSize(width: maxMessageWidth, height: messageHeight)) - } - - private func measureSentNonHero(state: LinkPreviewSent, hasImage: Bool) -> CGSize { - let maxMessageWidth = state.conversationStyle.maxMessageWidth - - var maxTextWidth = maxMessageWidth - 2 * sentNonHeroHMargin - if hasImage { - maxTextWidth -= (sentNonHeroImageSize + sentNonHeroHSpacing) - } - let textStackSize = sentTextStackSize(state: state, maxWidth: maxTextWidth) - - var result = textStackSize - - if hasImage { - result.width += sentNonHeroImageSize + sentNonHeroHSpacing - result.height = max(result.height, sentNonHeroImageSize) - } - - result.width += 2 * sentNonHeroHMargin - result.height += 2 * sentNonHeroVMargin - - return CGSizeCeil(result) - } - - private func sentTextStackSize(state: LinkPreviewSent, maxWidth: CGFloat) -> CGSize { - let domainLabel = sentDomainLabel(state: state) - let domainLabelSize = CGSizeCeil(domainLabel.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude))) - - var result = domainLabelSize - - if let titleLabel = sentTitleLabel(state: state) { - let titleLabelSize = CGSizeCeil(titleLabel.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude))) - let maxTitleLabelHeight: CGFloat = ceil(CGFloat(sentTitleLineCount) * titleLabel.font.lineHeight) - result.width = max(result.width, titleLabelSize.width) - result.height += min(maxTitleLabelHeight, titleLabelSize.height) + sentVSpacing - } - - return result - } - - @objc - public func addBorderViews(bubbleView: OWSBubbleView) { - if let heroImageView = self.heroImageView { - let borderView = OWSBubbleShapeView(draw: ()) - borderView.strokeColor = UIColor.clear - borderView.strokeThickness = 0 - heroImageView.addSubview(borderView) - bubbleView.addPartnerView(borderView) - borderView.ows_autoPinToSuperviewEdges() - } - if let sentBodyView = self.sentBodyView { - let borderView = OWSBubbleShapeView(draw: ()) - borderView.strokeColor = UIColor.clear - borderView.strokeThickness = 0 - sentBodyView.addSubview(borderView) - bubbleView.addPartnerView(borderView) - borderView.ows_autoPinToSuperviewEdges() - } else { - owsFailDebug("Missing sentBodyView") - } - } - - @objc func didTapCancel(sender: UIButton) { - self.draftDelegate?.linkPreviewDidCancel() - } -} diff --git a/Session/Conversations/Views & Cells/MediaDownloadView.swift b/Session/Conversations/Views & Cells/MediaDownloadView.swift deleted file mode 100644 index 01d680eb7..000000000 --- a/Session/Conversations/Views & Cells/MediaDownloadView.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public class MediaDownloadView: UIView { - - // MARK: - - - private let attachmentId: String - private let radius: CGFloat - private let shapeLayer1 = CAShapeLayer() - private let shapeLayer2 = CAShapeLayer() - - @objc - public required init(attachmentId: String, radius: CGFloat) { - self.attachmentId = attachmentId - self.radius = radius - - super.init(frame: .zero) - - shapeLayer1.zPosition = 1 - shapeLayer2.zPosition = 2 - layer.addSublayer(shapeLayer1) - layer.addSublayer(shapeLayer2) - - NotificationCenter.default.addObserver(forName: NSNotification.Name.attachmentDownloadProgress, object: nil, queue: nil) { [weak self] notification in - guard let strongSelf = self else { return } - guard let notificationAttachmentId = notification.userInfo?[kAttachmentDownloadAttachmentIDKey] as? String else { - return - } - guard notificationAttachmentId == strongSelf.attachmentId else { - return - } - strongSelf.updateLayers() - } - } - - @available(*, unavailable, message: "use other init() instead.") - required public init?(coder aDecoder: NSCoder) { - notImplemented() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc public override var bounds: CGRect { - didSet { - if oldValue != bounds { - updateLayers() - } - } - } - - @objc public override var frame: CGRect { - didSet { - if oldValue != frame { - updateLayers() - } - } - } - - internal func updateLayers() { - AssertIsOnMainThread() - - shapeLayer1.frame = self.bounds - shapeLayer2.frame = self.bounds - - shapeLayer1.path = nil - shapeLayer2.path = nil - return - - // We can't display download progress yet - - /* - // Prevent the shape layer from animating changes. - CATransaction.begin() - CATransaction.setDisableActions(true) - - let center = CGPoint(x: self.bounds.width * 0.5, - y: self.bounds.height * 0.5) - let outerRadius: CGFloat = radius * 1.0 - let innerRadius: CGFloat = radius * 0.9 - let startAngle: CGFloat = CGFloat.pi * 1.5 - let endAngle: CGFloat = CGFloat.pi * (1.5 + 2 * CGFloat(progress.floatValue)) - - let bezierPath1 = UIBezierPath() - bezierPath1.append(UIBezierPath(ovalIn: CGRect(origin: center.minus(CGPoint(x: innerRadius, - y: innerRadius)), - size: CGSize(width: innerRadius * 2, - height: innerRadius * 2)))) - bezierPath1.append(UIBezierPath(ovalIn: CGRect(origin: center.minus(CGPoint(x: outerRadius, - y: outerRadius)), - size: CGSize(width: outerRadius * 2, - height: outerRadius * 2)))) - shapeLayer1.path = bezierPath1.cgPath - let fillColor1: UIColor = UIColor(white: 1.0, alpha: 0.4) - shapeLayer1.fillColor = fillColor1.cgColor - shapeLayer1.fillRule = .evenOdd - - let bezierPath2 = UIBezierPath() - bezierPath2.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true) - bezierPath2.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false) - shapeLayer2.path = bezierPath2.cgPath - let fillColor2: UIColor = (Theme.isDarkThemeEnabled ? .ows_gray25 : .ows_white) - shapeLayer2.fillColor = fillColor2.cgColor - - CATransaction.commit() - */ - } -} diff --git a/Session/Conversations/Views & Cells/MediaUploadView.swift b/Session/Conversations/Views & Cells/MediaUploadView.swift deleted file mode 100644 index 57064617a..000000000 --- a/Session/Conversations/Views & Cells/MediaUploadView.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public class MediaUploadView: UIView { - - // MARK: - - - private let attachmentId: String - private let radius: CGFloat - private let shapeLayer1 = CAShapeLayer() - private let shapeLayer2 = CAShapeLayer() - - private var isAttachmentReady: Bool = false - private var lastProgress: CGFloat = 0 - - @objc - public required init(attachmentId: String, radius: CGFloat) { - self.attachmentId = attachmentId - self.radius = radius - - super.init(frame: .zero) - - layer.addSublayer(shapeLayer1) - layer.addSublayer(shapeLayer2) - } - - @available(*, unavailable, message: "use other init() instead.") - required public init?(coder aDecoder: NSCoder) { - notImplemented() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc public override var bounds: CGRect { - didSet { - if oldValue != bounds { - updateLayers() - } - } - } - - @objc public override var frame: CGRect { - didSet { - if oldValue != frame { - updateLayers() - } - } - } - - internal func updateLayers() { - AssertIsOnMainThread() - - shapeLayer1.frame = self.bounds - shapeLayer2.frame = self.bounds - - guard !isAttachmentReady else { - shapeLayer1.path = nil - shapeLayer2.path = nil - return - } - - // Prevent the shape layer from animating changes. - CATransaction.begin() - CATransaction.setDisableActions(true) - - let center = CGPoint(x: self.bounds.width * 0.5, - y: self.bounds.height * 0.5) - let outerRadius: CGFloat = radius * 1.0 - let innerRadius: CGFloat = radius * 0.9 - let startAngle: CGFloat = CGFloat.pi * 1.5 - let endAngle: CGFloat = CGFloat.pi * (1.5 + 2 * lastProgress) - - let bezierPath1 = UIBezierPath() - bezierPath1.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true) - bezierPath1.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false) - shapeLayer1.path = bezierPath1.cgPath - shapeLayer1.fillColor = UIColor.ows_white.cgColor - - let innerCircleBounds = CGRect(x: center.x - innerRadius, - y: center.y - innerRadius, - width: innerRadius * 2, - height: innerRadius * 2) - let outerCircleBounds = CGRect(x: center.x - outerRadius, - y: center.y - outerRadius, - width: outerRadius * 2, - height: outerRadius * 2) - let bezierPath2 = UIBezierPath() - bezierPath2.append(UIBezierPath(ovalIn: innerCircleBounds)) - bezierPath2.append(UIBezierPath(ovalIn: outerCircleBounds)) - shapeLayer2.path = bezierPath2.cgPath - shapeLayer2.fillColor = UIColor(white: 1.0, alpha: 0.4).cgColor - shapeLayer2.fillRule = .evenOdd - - CATransaction.commit() - } -} diff --git a/Session/Conversations/Views & Cells/OWSBubbleShapeView.h b/Session/Conversations/Views & Cells/OWSBubbleShapeView.h deleted file mode 100644 index 36fa08f09..000000000 --- a/Session/Conversations/Views & Cells/OWSBubbleShapeView.h +++ /dev/null @@ -1,42 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBubbleView.h" - -NS_ASSUME_NONNULL_BEGIN - -@class OWSBubbleView; - -// While rendering message bubbles, we often need to render -// into a subregion of the bubble that reflects the intersection -// of some subview (e.g. a media view) and the bubble shape -// (including its rounding). -// -// This view serves three different roles: -// -// * Drawing: Filling and/or stroking a subregion of the bubble shape. -// * Shadows: Casting a shadow over a subregion of the bubble shape. -// * Clipping: Clipping subviews to subregion of the bubble shape. -@interface OWSBubbleShapeView : UIView - -@property (nonatomic, nullable) UIColor *fillColor; -@property (nonatomic, nullable) UIColor *strokeColor; -@property (nonatomic) CGFloat strokeThickness; - -@property (nonatomic, nullable) UIColor *innerShadowColor; -@property (nonatomic) CGFloat innerShadowRadius; -@property (nonatomic) float innerShadowOpacity; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initDraw NS_DESIGNATED_INITIALIZER; -- (instancetype)initShadow NS_DESIGNATED_INITIALIZER; -- (instancetype)initClip NS_DESIGNATED_INITIALIZER; -- (instancetype)initInnerShadowWithColor:(UIColor *)color - radius:(CGFloat)radius - opacity:(float)opacity NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSBubbleShapeView.m b/Session/Conversations/Views & Cells/OWSBubbleShapeView.m deleted file mode 100644 index 405ec2efc..000000000 --- a/Session/Conversations/Views & Cells/OWSBubbleShapeView.m +++ /dev/null @@ -1,290 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBubbleShapeView.h" -#import "OWSBubbleView.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSUInteger, OWSBubbleShapeViewMode) { - // For stroking or filling. - OWSBubbleShapeViewMode_Draw, - OWSBubbleShapeViewMode_Shadow, - OWSBubbleShapeViewMode_Clip, - OWSBubbleShapeViewMode_InnerShadow, -}; - -@interface OWSBubbleShapeView () - -@property (nonatomic) OWSBubbleShapeViewMode mode; - -@property (nonatomic) CAShapeLayer *shapeLayer; -@property (nonatomic) CAShapeLayer *maskLayer; - -@property (nonatomic, nullable, weak) OWSBubbleView *bubbleView; -@property (nonatomic) BOOL isConfigured; - -@end - -#pragma mark - - -@implementation OWSBubbleShapeView - -- (void)configure -{ - self.opaque = NO; - self.backgroundColor = [UIColor clearColor]; - self.layoutMargins = UIEdgeInsetsZero; - - self.shapeLayer = [CAShapeLayer new]; - [self.layer addSublayer:self.shapeLayer]; - - self.maskLayer = [CAShapeLayer new]; - - self.isConfigured = YES; - - [self updateLayers]; -} - -- (instancetype)initDraw -{ - self = [super initWithFrame:CGRectZero]; - if (!self) { - return self; - } - - self.mode = OWSBubbleShapeViewMode_Draw; - - [self configure]; - - return self; -} - -- (instancetype)initShadow -{ - self = [super initWithFrame:CGRectZero]; - if (!self) { - return self; - } - - self.mode = OWSBubbleShapeViewMode_Shadow; - - [self configure]; - - return self; -} - -- (instancetype)initClip -{ - self = [super initWithFrame:CGRectZero]; - if (!self) { - return self; - } - - self.mode = OWSBubbleShapeViewMode_Clip; - - [self configure]; - - return self; -} - -- (instancetype)initInnerShadowWithColor:(UIColor *)color radius:(CGFloat)radius opacity:(float)opacity -{ - self = [super initWithFrame:CGRectZero]; - if (!self) { - return self; - } - - self.mode = OWSBubbleShapeViewMode_InnerShadow; - _innerShadowColor = color; - _innerShadowRadius = radius; - _innerShadowOpacity = opacity; - - [self configure]; - - return self; -} - -- (void)setFillColor:(nullable UIColor *)fillColor -{ - _fillColor = fillColor; - - [self updateLayers]; -} - -- (void)setStrokeColor:(nullable UIColor *)strokeColor -{ - _strokeColor = strokeColor; - - [self updateLayers]; -} - -- (void)setStrokeThickness:(CGFloat)strokeThickness -{ - _strokeThickness = strokeThickness; - - [self updateLayers]; -} - -- (void)setInnerShadowColor:(nullable UIColor *)innerShadowColor -{ - _innerShadowColor = innerShadowColor; - - [self updateLayers]; -} - -- (void)setInnerShadowRadius:(CGFloat)innerShadowRadius -{ - _innerShadowRadius = innerShadowRadius; - - [self updateLayers]; -} - -- (void)setInnerShadowOpacity:(float)innerShadowOpacity -{ - _innerShadowOpacity = innerShadowOpacity; - - [self updateLayers]; -} - -- (void)setFrame:(CGRect)frame -{ - BOOL didChange = !CGRectEqualToRect(self.frame, frame); - - [super setFrame:frame]; - - if (didChange) { - [self updateLayers]; - } -} - -- (void)setBounds:(CGRect)bounds -{ - BOOL didChange = !CGRectEqualToRect(self.bounds, bounds); - - [super setBounds:bounds]; - - if (didChange) { - [self updateLayers]; - } -} - -- (void)setCenter:(CGPoint)center -{ - [super setCenter:center]; - - [self updateLayers]; -} - -- (void)setBubbleView:(nullable OWSBubbleView *)bubbleView -{ - _bubbleView = bubbleView; - - [self updateLayers]; -} - -- (void)updateLayers -{ - if (!self.shapeLayer) { - return; - } - if (!self.bubbleView) { - return; - } - if (!self.isConfigured) { - return; - } - - // Prevent the layer from animating changes. - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - - UIBezierPath *bezierPath = [UIBezierPath new]; - - // Add the bubble view's path to the local path. - UIBezierPath *bubbleBezierPath = [self.bubbleView maskPath]; - // We need to convert between coordinate systems using layers, not views. - CGPoint bubbleOffset = [self.layer convertPoint:CGPointZero fromLayer:self.bubbleView.layer]; - CGAffineTransform transform = CGAffineTransformMakeTranslation(bubbleOffset.x, bubbleOffset.y); - [bubbleBezierPath applyTransform:transform]; - [bezierPath appendPath:bubbleBezierPath]; - - switch (self.mode) { - case OWSBubbleShapeViewMode_Draw: { - UIBezierPath *boundsBezierPath = [UIBezierPath bezierPathWithRect:self.bounds]; - [bezierPath appendPath:boundsBezierPath]; - - self.clipsToBounds = YES; - - if (self.strokeColor) { - self.shapeLayer.strokeColor = self.strokeColor.CGColor; - self.shapeLayer.lineWidth = self.strokeThickness; - self.shapeLayer.zPosition = 100.f; - } else { - self.shapeLayer.strokeColor = nil; - self.shapeLayer.lineWidth = 0.f; - } - if (self.fillColor) { - self.shapeLayer.fillColor = self.fillColor.CGColor; - } else { - self.shapeLayer.fillColor = nil; - } - - self.shapeLayer.path = bezierPath.CGPath; - - break; - } - case OWSBubbleShapeViewMode_Shadow: - self.clipsToBounds = NO; - - if (self.fillColor) { - self.shapeLayer.fillColor = self.fillColor.CGColor; - } else { - self.shapeLayer.fillColor = nil; - } - - self.shapeLayer.path = bezierPath.CGPath; - self.shapeLayer.frame = self.bounds; - self.shapeLayer.masksToBounds = YES; - - break; - case OWSBubbleShapeViewMode_Clip: - self.maskLayer.path = bezierPath.CGPath; - self.layer.mask = self.maskLayer; - break; - case OWSBubbleShapeViewMode_InnerShadow: { - self.maskLayer.path = bezierPath.CGPath; - self.layer.mask = self.maskLayer; - - // Inner shadow. - // This should usually not be visible; it is used to distinguish - // profile pics from the background if they are similar. - self.shapeLayer.frame = self.bounds; - self.shapeLayer.masksToBounds = YES; - CGRect shadowBounds = self.bounds; - UIBezierPath *shadowPath = [bezierPath copy]; - // This can be any value large enough to cast a sufficiently large shadow. - CGFloat shadowInset = -(self.innerShadowRadius * 4.f); - [shadowPath - appendPath:[UIBezierPath bezierPathWithRect:CGRectInset(shadowBounds, shadowInset, shadowInset)]]; - // This can be any color since the fill should be clipped. - self.shapeLayer.fillColor = UIColor.blackColor.CGColor; - self.shapeLayer.path = shadowPath.CGPath; - self.shapeLayer.fillRule = kCAFillRuleEvenOdd; - self.shapeLayer.shadowColor = self.innerShadowColor.CGColor; - self.shapeLayer.shadowRadius = self.innerShadowRadius; - self.shapeLayer.shadowOpacity = self.innerShadowOpacity; - self.shapeLayer.shadowOffset = CGSizeZero; - - break; - } - } - - [CATransaction commit]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSBubbleView.h b/Session/Conversations/Views & Cells/OWSBubbleView.h deleted file mode 100644 index 5119d3c1a..000000000 --- a/Session/Conversations/Views & Cells/OWSBubbleView.h +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -extern const CGFloat kOWSMessageCellCornerRadius_Large; -extern const CGFloat kOWSMessageCellCornerRadius_Small; - -typedef NS_OPTIONS(NSUInteger, OWSDirectionalRectCorner) { - OWSDirectionalRectCornerTopLeading = 1 << 0, - OWSDirectionalRectCornerTopTrailing = 1 << 1, - OWSDirectionalRectCornerBottomLeading = 1 << 2, - OWSDirectionalRectCornerBottomTrailing = 1 << 3, - OWSDirectionalRectCornerAllCorners = ~0UL -}; - -@class OWSBubbleView; - -@protocol OWSBubbleViewPartner - -- (void)updateLayers; - -- (void)setBubbleView:(nullable OWSBubbleView *)bubbleView; - -@end - -#pragma mark - - -@interface OWSBubbleView : UIView - -+ (UIBezierPath *)roundedBezierRectWithBubbleTop:(CGFloat)bubbleTop - bubbleLeft:(CGFloat)bubbleLeft - bubbleBottom:(CGFloat)bubbleBottom - bubbleRight:(CGFloat)bubbleRight - sharpCornerRadius:(CGFloat)sharpCornerRadius - wideCornerRadius:(CGFloat)wideCornerRadius - sharpCorners:(OWSDirectionalRectCorner)sharpCorners; - -@property (nonatomic, nullable) UIColor *bubbleColor; - -@property (nonatomic) OWSDirectionalRectCorner sharpCorners; - -- (UIBezierPath *)maskPath; - -#pragma mark - Coordination - -- (void)addPartnerView:(id)view; - -- (void)clearPartnerViews; - -- (void)updatePartnerViews; - -- (CGFloat)minWidth; - -- (CGFloat)minHeight; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSBubbleView.m b/Session/Conversations/Views & Cells/OWSBubbleView.m deleted file mode 100644 index 1f6148f98..000000000 --- a/Session/Conversations/Views & Cells/OWSBubbleView.m +++ /dev/null @@ -1,284 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSBubbleView.h" -#import "MainAppContext.h" -#import -#import "Session-Swift.h" - -NS_ASSUME_NONNULL_BEGIN - -UIRectCorner UIRectCornerForOWSDirectionalRectCorner(OWSDirectionalRectCorner corner); -UIRectCorner UIRectCornerForOWSDirectionalRectCorner(OWSDirectionalRectCorner corner) -{ - if (corner == OWSDirectionalRectCornerAllCorners) { - return UIRectCornerAllCorners; - } - - UIRectCorner rectCorner = 0; - BOOL isRTL = CurrentAppContext().isRTL; - - if (corner & OWSDirectionalRectCornerTopLeading) { - rectCorner = rectCorner | (isRTL ? UIRectCornerTopRight : UIRectCornerTopLeft); - } - - if (corner & OWSDirectionalRectCornerTopTrailing) { - rectCorner = rectCorner | (isRTL ? UIRectCornerTopLeft : UIRectCornerTopRight); - } - - if (corner & OWSDirectionalRectCornerBottomTrailing) { - rectCorner = rectCorner | (isRTL ? UIRectCornerBottomLeft : UIRectCornerBottomRight); - } - - if (corner & OWSDirectionalRectCornerBottomLeading) { - rectCorner = rectCorner | (isRTL ? UIRectCornerBottomRight : UIRectCornerBottomLeft); - } - - return rectCorner; -} - -const CGFloat kOWSMessageCellCornerRadius_Large = 10; // LKValues.messageBubbleCornerRadius -const CGFloat kOWSMessageCellCornerRadius_Small = 4; - -@interface OWSBubbleView () - -@property (nonatomic) CAShapeLayer *maskLayer; -@property (nonatomic) CAShapeLayer *shapeLayer; - -@property (nonatomic, readonly) NSMutableArray> *partnerViews; - -@end - -#pragma mark - - -@implementation OWSBubbleView - -- (instancetype)init -{ - self = [super init]; - if (!self) { - return self; - } - - self.layoutMargins = UIEdgeInsetsZero; - - self.shapeLayer = [CAShapeLayer new]; - [self.layer addSublayer:self.shapeLayer]; - - self.maskLayer = [CAShapeLayer new]; - self.layer.mask = self.maskLayer; - - _partnerViews = [NSMutableArray new]; - - return self; -} - -- (void)setFrame:(CGRect)frame -{ - // We only need to update our layers if the _size_ of this view - // changes since the contents of the layers are in local coordinates. - BOOL didChangeSize = !CGSizeEqualToSize(self.frame.size, frame.size); - - [super setFrame:frame]; - - if (didChangeSize) { - [self updateLayers]; - } - - // We always need to inform the "bubble stroke view" (if any) if our - // frame/bounds/center changes. Its contents are not in local coordinates. - [self updatePartnerViews]; -} - -- (void)setBounds:(CGRect)bounds -{ - // We only need to update our layers if the _size_ of this view - // changes since the contents of the layers are in local coordinates. - BOOL didChangeSize = !CGSizeEqualToSize(self.bounds.size, bounds.size); - - [super setBounds:bounds]; - - if (didChangeSize) { - [self updateLayers]; - } - - // We always need to inform the "bubble stroke view" (if any) if our - // frame/bounds/center changes. Its contents are not in local coordinates. - [self updatePartnerViews]; -} - -- (void)setCenter:(CGPoint)center -{ - [super setCenter:center]; - - // We always need to inform the "bubble stroke view" (if any) if our - // frame/bounds/center changes. Its contents are not in local coordinates. - [self updatePartnerViews]; -} - -- (void)setBubbleColor:(nullable UIColor *)bubbleColor -{ - _bubbleColor = bubbleColor; - - [self updateLayers]; - - // Prevent the shape layer from animating changes. - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - - self.shapeLayer.fillColor = bubbleColor.CGColor; - - [CATransaction commit]; -} - -- (void)setSharpCorners:(OWSDirectionalRectCorner)sharpCorners -{ - _sharpCorners = sharpCorners; - - [self updateLayers]; -} - -- (void)updateLayers -{ - if (!self.maskLayer) { - return; - } - if (!self.shapeLayer) { - return; - } - - UIBezierPath *bezierPath = [self maskPath]; - - // Prevent the shape layer from animating changes. - [CATransaction begin]; - [CATransaction setDisableActions:YES]; - - self.shapeLayer.fillColor = self.bubbleColor.CGColor; - self.shapeLayer.path = bezierPath.CGPath; - self.maskLayer.path = bezierPath.CGPath; - - [CATransaction commit]; -} - -- (UIBezierPath *)maskPath -{ - return [self.class maskPathForSize:self.bounds.size sharpCorners:self.sharpCorners]; -} - -+ (UIBezierPath *)maskPathForSize:(CGSize)size sharpCorners:(OWSDirectionalRectCorner)sharpCorners -{ - CGRect bounds = CGRectZero; - bounds.size = size; - - CGFloat bubbleTop = 0.f; - CGFloat bubbleLeft = 0.f; - CGFloat bubbleBottom = size.height; - CGFloat bubbleRight = size.width; - - return [OWSBubbleView roundedBezierRectWithBubbleTop:bubbleTop - bubbleLeft:bubbleLeft - bubbleBottom:bubbleBottom - bubbleRight:bubbleRight - sharpCornerRadius:kOWSMessageCellCornerRadius_Small - wideCornerRadius:kOWSMessageCellCornerRadius_Large - sharpCorners:sharpCorners]; -} - -+ (UIBezierPath *)roundedBezierRectWithBubbleTop:(CGFloat)bubbleTop - bubbleLeft:(CGFloat)bubbleLeft - bubbleBottom:(CGFloat)bubbleBottom - bubbleRight:(CGFloat)bubbleRight - sharpCornerRadius:(CGFloat)sharpCornerRadius - wideCornerRadius:(CGFloat)wideCornerRadius - sharpCorners:(OWSDirectionalRectCorner)sharpCorners -{ - UIBezierPath *bezierPath = [UIBezierPath new]; - - UIRectCorner uiSharpCorners = UIRectCornerForOWSDirectionalRectCorner(sharpCorners); - - const CGFloat topLeftRounding = (uiSharpCorners & UIRectCornerTopLeft) ? sharpCornerRadius : wideCornerRadius; - const CGFloat topRightRounding = (uiSharpCorners & UIRectCornerTopRight) ? sharpCornerRadius : wideCornerRadius; - - const CGFloat bottomRightRounding - = (uiSharpCorners & UIRectCornerBottomRight) ? sharpCornerRadius : wideCornerRadius; - const CGFloat bottomLeftRounding = (uiSharpCorners & UIRectCornerBottomLeft) ? sharpCornerRadius : wideCornerRadius; - - const CGFloat topAngle = 3.0f * M_PI_2; - const CGFloat rightAngle = 0.0f; - const CGFloat bottomAngle = M_PI_2; - const CGFloat leftAngle = M_PI; - - // starting just to the right of the top left corner and working clockwise - [bezierPath moveToPoint:CGPointMake(bubbleLeft + topLeftRounding, bubbleTop)]; - - // top right corner - [bezierPath addArcWithCenter:CGPointMake(bubbleRight - topRightRounding, bubbleTop + topRightRounding) - radius:topRightRounding - startAngle:topAngle - endAngle:rightAngle - clockwise:true]; - - // bottom right corner - [bezierPath addArcWithCenter:CGPointMake(bubbleRight - bottomRightRounding, bubbleBottom - bottomRightRounding) - radius:bottomRightRounding - startAngle:rightAngle - endAngle:bottomAngle - clockwise:true]; - - // bottom left corner - [bezierPath addArcWithCenter:CGPointMake(bubbleLeft + bottomLeftRounding, bubbleBottom - bottomLeftRounding) - radius:bottomLeftRounding - startAngle:bottomAngle - endAngle:leftAngle - clockwise:true]; - - // top left corner - [bezierPath addArcWithCenter:CGPointMake(bubbleLeft + topLeftRounding, bubbleTop + topLeftRounding) - radius:topLeftRounding - startAngle:leftAngle - endAngle:topAngle - clockwise:true]; - return bezierPath; -} - -#pragma mark - Coordination - -- (void)addPartnerView:(id)partnerView -{ - OWSAssertDebug(self.partnerViews); - - [partnerView setBubbleView:self]; - - [self.partnerViews addObject:partnerView]; -} - -- (void)clearPartnerViews -{ - OWSAssertDebug(self.partnerViews); - - [self.partnerViews removeAllObjects]; -} - -- (void)updatePartnerViews -{ - [self layoutIfNeeded]; - - for (id partnerView in self.partnerViews) { - [partnerView updateLayers]; - } -} - -- (CGFloat)minWidth -{ - return (kOWSMessageCellCornerRadius_Large * 2); -} - -- (CGFloat)minHeight -{ - return (kOWSMessageCellCornerRadius_Large * 2); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSGenericAttachmentView.h b/Session/Conversations/Views & Cells/OWSGenericAttachmentView.h deleted file mode 100644 index a06a54494..000000000 --- a/Session/Conversations/Views & Cells/OWSGenericAttachmentView.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; -@class TSAttachment; - -@protocol ConversationViewItem; - -@interface OWSGenericAttachmentView : UIStackView - -- (instancetype)initWithAttachment:(TSAttachment *)attachment - isIncoming:(BOOL)isIncoming - viewItem:(id)viewItem; - -- (void)createContentsWithConversationStyle:(ConversationStyle *)conversationStyle; - -- (CGSize)measureSizeWithMaxMessageWidth:(CGFloat)maxMessageWidth; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSGenericAttachmentView.m b/Session/Conversations/Views & Cells/OWSGenericAttachmentView.m deleted file mode 100644 index 90a8e8fcd..000000000 --- a/Session/Conversations/Views & Cells/OWSGenericAttachmentView.m +++ /dev/null @@ -1,242 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSGenericAttachmentView.h" -#import "OWSBezierPathView.h" -#import "Session-Swift.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" - -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSGenericAttachmentView () - -@property (nonatomic) TSAttachment *attachment; -@property (nonatomic, nullable) TSAttachmentStream *attachmentStream; -@property (nonatomic, weak) id viewItem; -@property (nonatomic) BOOL isIncoming; -@property (nonatomic) UILabel *topLabel; -@property (nonatomic) UILabel *bottomLabel; - -@end - -#pragma mark - - -@implementation OWSGenericAttachmentView - -- (instancetype)initWithAttachment:(TSAttachment *)attachment - isIncoming:(BOOL)isIncoming - viewItem:(id)viewItem -{ - self = [super init]; - - if (self) { - _attachment = attachment; - if ([attachment isKindOfClass:[TSAttachmentStream class]]) { - _attachmentStream = (TSAttachmentStream *)attachment; - } - _isIncoming = isIncoming; - _viewItem = viewItem; - } - - return self; -} - -#pragma mark - - -- (CGFloat)hMargin -{ - return 0.f; -} - -- (CGFloat)hSpacing -{ - return 8.f; -} - -- (CGFloat)vMargin -{ - return 4.f; -} - -- (CGSize)measureSizeWithMaxMessageWidth:(CGFloat)maxMessageWidth -{ - CGSize result = CGSizeZero; - - CGFloat labelsHeight = ([OWSGenericAttachmentView topLabelFont].lineHeight + - [OWSGenericAttachmentView bottomLabelFont].lineHeight + [OWSGenericAttachmentView labelVSpacing]); - CGFloat contentHeight = MAX(self.iconHeight, labelsHeight); - result.height = contentHeight + self.vMargin * 2; - - CGFloat labelsWidth - = MAX([self.topLabel sizeThatFits:CGSizeZero].width, [self.bottomLabel sizeThatFits:CGSizeZero].width); - CGFloat contentWidth = (self.iconWidth + labelsWidth + self.hSpacing); - result.width = MIN(maxMessageWidth, contentWidth + self.hMargin * 2); - - return CGSizeCeil(result); -} - -- (CGFloat)iconWidth -{ - return 36.f; -} - -- (CGFloat)iconHeight -{ - return 48.0f; -} - -- (void)createContentsWithConversationStyle:(ConversationStyle *)conversationStyle -{ - OWSAssertDebug(conversationStyle); - - self.axis = UILayoutConstraintAxisHorizontal; - self.alignment = UIStackViewAlignmentCenter; - self.spacing = self.hSpacing; - self.layoutMarginsRelativeArrangement = YES; - self.layoutMargins = UIEdgeInsetsMake(self.vMargin, 0, self.vMargin - 4, 0); - - // attachment_file - UIImage *image = [UIImage imageNamed:@"generic-attachment"]; - OWSAssertDebug(image); - OWSAssertDebug(image.size.width == self.iconWidth); - OWSAssertDebug(image.size.height == self.iconHeight); - UIImageView *imageView = [UIImageView new]; - imageView.image = image; - [self addArrangedSubview:imageView]; - [imageView setContentHuggingHigh]; - - NSString *_Nullable filename = self.attachment.sourceFilename; - if (!filename) { - filename = [[self.attachmentStream originalFilePath] lastPathComponent]; - } - NSString *fileExtension = filename.pathExtension; - if (fileExtension.length < 1) { - fileExtension = [MIMETypeUtil fileExtensionForMIMEType:self.attachment.contentType]; - } - - UILabel *fileTypeLabel = [UILabel new]; - fileTypeLabel.text = fileExtension.localizedUppercaseString; - fileTypeLabel.textColor = [UIColor ows_gray90Color]; - fileTypeLabel.lineBreakMode = NSLineBreakByTruncatingTail; - fileTypeLabel.font = [UIFont ows_dynamicTypeCaption1Font].ows_mediumWeight; - fileTypeLabel.adjustsFontSizeToFitWidth = YES; - fileTypeLabel.textAlignment = NSTextAlignmentCenter; - // Center on icon. - [imageView addSubview:fileTypeLabel]; - [fileTypeLabel autoCenterInSuperview]; - [fileTypeLabel autoSetDimension:ALDimensionWidth toSize:self.iconWidth - 20.f]; - - [self replaceIconWithDownloadProgressIfNecessary:imageView]; - - UIStackView *labelsView = [UIStackView new]; - labelsView.axis = UILayoutConstraintAxisVertical; - labelsView.spacing = [OWSGenericAttachmentView labelVSpacing]; - labelsView.alignment = UIStackViewAlignmentLeading; - [self addArrangedSubview:labelsView]; - - NSString *topText = [self.attachment.sourceFilename ows_stripped]; - if (topText.length < 1) { - topText = [MIMETypeUtil fileExtensionForMIMEType:self.attachment.contentType].localizedUppercaseString; - } - if (topText.length < 1) { - topText = NSLocalizedString(@"GENERIC_ATTACHMENT_LABEL", @"A label for generic attachments."); - } - UILabel *topLabel = [UILabel new]; - self.topLabel = topLabel; - topLabel.text = topText; - topLabel.textColor = [conversationStyle bubbleTextColorWithIsIncoming:self.isIncoming]; - topLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - topLabel.font = [OWSGenericAttachmentView topLabelFont]; - [labelsView addArrangedSubview:topLabel]; - - unsigned long long fileSize = 0; - if (self.attachmentStream) { - NSError *error; - fileSize = [[NSFileManager defaultManager] attributesOfItemAtPath:[self.attachmentStream originalFilePath] - error:&error] - .fileSize; - OWSAssertDebug(!error); - } - // We don't want to show the file size while the attachment is downloading. - // To avoid layout jitter when the download completes, we reserve space in - // the layout using a whitespace string. - NSString *bottomText = @" "; - if (fileSize > 0) { - bottomText = [OWSFormat formatFileSize:fileSize]; - } - UILabel *bottomLabel = [UILabel new]; - self.bottomLabel = bottomLabel; - bottomLabel.text = bottomText; - bottomLabel.textColor = [conversationStyle bubbleSecondaryTextColorWithIsIncoming:self.isIncoming]; - bottomLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - bottomLabel.font = [OWSGenericAttachmentView bottomLabelFont]; - [labelsView addArrangedSubview:bottomLabel]; -} - -- (void)replaceIconWithDownloadProgressIfNecessary:(UIView *)iconView -{ - if (!self.viewItem.attachmentPointer) { - return; - } - - switch (self.viewItem.attachmentPointer.state) { - case TSAttachmentPointerStateFailed: - // We don't need to handle the "tap to retry" state here, - // only download progress. - return; - case TSAttachmentPointerStateEnqueued: - case TSAttachmentPointerStateDownloading: - break; - } - switch (self.viewItem.attachmentPointer.pointerType) { - case TSAttachmentPointerTypeRestoring: - // TODO: Show "restoring" indicator and possibly progress. - return; - case TSAttachmentPointerTypeUnknown: - case TSAttachmentPointerTypeIncoming: - break; - } - NSString *_Nullable uniqueId = self.viewItem.attachmentPointer.uniqueId; - if (uniqueId.length < 1) { - OWSFailDebug(@"Missing uniqueId."); - return; - } - - CGSize iconViewSize = [iconView sizeThatFits:CGSizeZero]; - CGFloat downloadViewSize = MIN(iconViewSize.width, iconViewSize.height); - MediaDownloadView *downloadView = - [[MediaDownloadView alloc] initWithAttachmentId:uniqueId radius:downloadViewSize * 0.5f]; - iconView.layer.opacity = 0.01f; - [self addSubview:downloadView]; - [downloadView autoSetDimensionsToSize:CGSizeMake(downloadViewSize, downloadViewSize)]; - [downloadView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:iconView]; - [downloadView autoAlignAxis:ALAxisVertical toSameAxisOfView:iconView]; -} - -+ (UIFont *)topLabelFont -{ - return [UIFont systemFontOfSize:LKValues.mediumFontSize]; -} - -+ (UIFont *)bottomLabelFont -{ - return [UIFont ows_dynamicTypeCaption1Font]; -} - -+ (CGFloat)labelVSpacing -{ - return 2.f; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSLabel.h b/Session/Conversations/Views & Cells/OWSLabel.h deleted file mode 100644 index 89992ef6f..000000000 --- a/Session/Conversations/Views & Cells/OWSLabel.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSLabel : UILabel - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSLabel.m b/Session/Conversations/Views & Cells/OWSLabel.m deleted file mode 100644 index 6708ed400..000000000 --- a/Session/Conversations/Views & Cells/OWSLabel.m +++ /dev/null @@ -1,112 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSLabel.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSLabel () - -@property (nonatomic, nullable) NSValue *cachedSize; - -@end - -#pragma mark - - -@implementation OWSLabel - -- (void)setText:(nullable NSString *)text -{ - if ([NSObject isNullableObject:text equalTo:self.text]) { - return; - } - [super setText:text]; - self.cachedSize = nil; -} - -- (void)setAttributedText:(nullable NSAttributedString *)attributedText -{ - if ([NSObject isNullableObject:attributedText equalTo:self.attributedText]) { - return; - } - [super setAttributedText:attributedText]; - self.cachedSize = nil; -} - -- (void)setTextColor:(nullable UIColor *)textColor -{ - if ([NSObject isNullableObject:textColor equalTo:self.textColor]) { - return; - } - [super setTextColor:textColor]; - // No need to clear cached size here. -} - -- (void)setFont:(nullable UIFont *)font -{ - if ([NSObject isNullableObject:font equalTo:self.font]) { - return; - } - [super setFont:font]; - self.cachedSize = nil; -} - -- (void)setLineBreakMode:(NSLineBreakMode)lineBreakMode -{ - if (self.lineBreakMode == lineBreakMode) { - return; - } - [super setLineBreakMode:lineBreakMode]; - self.cachedSize = nil; -} - -- (void)setNumberOfLines:(NSInteger)numberOfLines -{ - if (self.numberOfLines == numberOfLines) { - return; - } - [super setNumberOfLines:numberOfLines]; - self.cachedSize = nil; -} - -- (void)setAdjustsFontSizeToFitWidth:(BOOL)adjustsFontSizeToFitWidth -{ - if (self.adjustsFontSizeToFitWidth == adjustsFontSizeToFitWidth) { - return; - } - [super setAdjustsFontSizeToFitWidth:adjustsFontSizeToFitWidth]; - self.cachedSize = nil; -} - -- (void)setMinimumScaleFactor:(CGFloat)minimumScaleFactor -{ - if (self.minimumScaleFactor == minimumScaleFactor) { - return; - } - [super setMinimumScaleFactor:minimumScaleFactor]; - self.cachedSize = nil; -} - -- (void)setMinimumFontSize:(CGFloat)minimumFontSize -{ - if (self.minimumFontSize == minimumFontSize) { - return; - } - [super setMinimumFontSize:minimumFontSize]; - self.cachedSize = nil; -} - -- (CGSize)sizeThatFits:(CGSize)size -{ - if (self.cachedSize) { - return self.cachedSize.CGSizeValue; - } - CGSize result = [super sizeThatFits:size]; - self.cachedSize = [NSValue valueWithCGSize:result]; - return result; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageBubbleView.h b/Session/Conversations/Views & Cells/OWSMessageBubbleView.h deleted file mode 100644 index 4db47bec3..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageBubbleView.h +++ /dev/null @@ -1,101 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ContactShareViewModel; -@class ConversationStyle; - -@protocol ConversationViewItem; - -@class OWSContact; -@class OWSLinkPreview; -@class OWSQuotedReplyModel; -@class TSAttachmentPointer; -@class TSAttachmentStream; -@class TSOutgoingMessage; - -typedef NS_ENUM(NSUInteger, OWSMessageGestureLocation) { - // Message text, etc. - OWSMessageGestureLocation_Default, - OWSMessageGestureLocation_OversizeText, - OWSMessageGestureLocation_Media, - OWSMessageGestureLocation_QuotedReply, - OWSMessageGestureLocation_LinkPreview, -}; - -@protocol OWSMessageBubbleViewDelegate - -- (void)didTapImageViewItem:(id)viewItem - attachmentStream:(TSAttachmentStream *)attachmentStream - imageView:(UIView *)imageView; - -- (void)didTapVideoViewItem:(id)viewItem - attachmentStream:(TSAttachmentStream *)attachmentStream - imageView:(UIView *)imageView; - -- (void)didTapAudioViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream; -- (void)didPanAudioViewItemToCurrentTime:(NSTimeInterval)currentTime; - -- (void)didTapTruncatedTextMessage:(id)conversationItem; - -- (void)didTapFailedIncomingAttachment:(id)viewItem; - -- (void)didTapConversationItem:(id)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply; -- (void)didTapConversationItem:(id)viewItem - quotedReply:(OWSQuotedReplyModel *)quotedReply - failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer; - -- (void)didTapConversationItem:(id)viewItem linkPreview:(OWSLinkPreview *)linkPreview; - -@property (nonatomic, readonly, nullable) NSString *lastSearchedText; - -@end - -#pragma mark - - -@interface OWSMessageBubbleView : UIView - -@property (nonatomic, nullable) id viewItem; - -@property (nonatomic) ConversationStyle *conversationStyle; - -@property (nonatomic) NSCache *cellMediaCache; - -@property (nonatomic, nullable, readonly) UIView *bodyMediaView; - -@property (nonatomic, weak) id delegate; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithFrame:(CGRect)frame NS_DESIGNATED_INITIALIZER; - -- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; - -- (void)configureViews; - -- (void)loadContent; -- (void)unloadContent; - -- (CGSize)measureSize; - -- (void)prepareForReuse; - -+ (NSDictionary *)senderNamePrimaryAttributes; -+ (NSDictionary *)senderNameSecondaryAttributes; - -#pragma mark - Gestures - -- (OWSMessageGestureLocation)gestureLocationForLocation:(CGPoint)locationInMessageBubble; - -// This only needs to be called when we use the cell _outside_ the context -// of a conversation view message cell. -- (void)addTapGestureHandler; - -- (void)handleTapGesture:(UITapGestureRecognizer *)sender; -- (void)handlePanGesture:(UIPanGestureRecognizer *)sender; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageBubbleView.m b/Session/Conversations/Views & Cells/OWSMessageBubbleView.m deleted file mode 100644 index 73384fe01..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageBubbleView.m +++ /dev/null @@ -1,1427 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSMessageBubbleView.h" -#import "AttachmentUploadView.h" -#import "ConversationViewItem.h" -#import "OWSBubbleShapeView.h" -#import "OWSBubbleView.h" -#import "OWSGenericAttachmentView.h" -#import "OWSLabel.h" -#import "OWSMessageFooterView.h" -#import "OWSMessageTextView.h" -#import "OWSQuotedMessageView.h" -#import "Session-Swift.h" -#import "UIColor+OWS.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageBubbleView () - -@property (nonatomic) OWSBubbleView *bubbleView; - -@property (nonatomic) UIStackView *stackView; - -@property (nonatomic) UILabel *senderNameLabel; - -@property (nonatomic) UIView *senderNameContainer; - -@property (nonatomic) OWSMessageTextView *bodyTextView; - -@property (nonatomic, nullable) UIView *quotedMessageView; - -@property (nonatomic, nullable) UIView *bodyMediaView; - -@property (nonatomic) LinkPreviewView *linkPreviewView; - -// Should lazy-load expensive view contents (images, etc.). -// Should do nothing if view is already loaded. -@property (nonatomic, nullable) dispatch_block_t loadCellContentBlock; -// Should unload all expensive view contents (images, etc.). -@property (nonatomic, nullable) dispatch_block_t unloadCellContentBlock; - -@property (nonatomic, nullable) NSMutableArray *viewConstraints; - -@property (nonatomic) OWSMessageFooterView *footerView; - -@end - -#pragma mark - - -@implementation OWSMessageBubbleView - -#pragma mark - - -- (instancetype)initWithFrame:(CGRect)frame -{ - self = [super initWithFrame:frame]; - - if (!self) { - return self; - } - - [self commonInit]; - - return self; -} - -- (void)commonInit -{ - // Ensure only called once. - OWSAssertDebug(!self.bodyTextView); - - _viewConstraints = [NSMutableArray new]; - - self.layoutMargins = UIEdgeInsetsZero; - self.userInteractionEnabled = YES; - - self.bubbleView = [OWSBubbleView new]; - self.bubbleView.layoutMargins = UIEdgeInsetsZero; - [self addSubview:self.bubbleView]; - [self.bubbleView autoPinEdgesToSuperviewEdges]; - - self.stackView = [UIStackView new]; - self.stackView.axis = UILayoutConstraintAxisVertical; - - self.senderNameLabel = [OWSLabel new]; - self.senderNameContainer = [UIView new]; - self.senderNameContainer.layoutMargins = UIEdgeInsetsMake(0, 0, self.senderNameBottomSpacing, 0); - [self.senderNameContainer addSubview:self.senderNameLabel]; - [self.senderNameLabel ows_autoPinToSuperviewMargins]; - - self.bodyTextView = [self newTextView]; - self.bodyTextView.hidden = YES; - - self.linkPreviewView = [[LinkPreviewView alloc] initWithDraftDelegate:nil]; - - self.footerView = [OWSMessageFooterView new]; -} - -- (OWSMessageTextView *)newTextView -{ - OWSMessageTextView *textView = [OWSMessageTextView new]; - textView.backgroundColor = [UIColor clearColor]; - textView.opaque = NO; - textView.editable = NO; - textView.selectable = YES; - textView.textContainerInset = UIEdgeInsetsZero; - textView.contentInset = UIEdgeInsetsZero; - textView.textContainer.lineFragmentPadding = 0; - textView.scrollEnabled = NO; - return textView; -} - -- (UIFont *)textMessageFont -{ - OWSAssertDebug(DisplayableText.kMaxJumbomojiCount == 5); - - CGFloat basePointSize = UIFont.ows_dynamicTypeBodyFont.pointSize; - switch (self.displayableBodyText.jumbomojiCount) { - case 0: - break; - case 1: - return [UIFont ows_regularFontWithSize:basePointSize + 30.f]; - case 2: - return [UIFont ows_regularFontWithSize:basePointSize + 24.f]; - case 3: - case 4: - case 5: - return [UIFont ows_regularFontWithSize:basePointSize + 18.f]; - default: - OWSFailDebug(@"Unexpected jumbomoji count: %zd", self.displayableBodyText.jumbomojiCount); - break; - } - - return [UIFont systemFontOfSize:LKValues.mediumFontSize]; -} - -#pragma mark - Convenience Accessors - -- (OWSMessageCellType)cellType -{ - return self.viewItem.messageCellType; -} - -- (BOOL)hasBodyText -{ - // This should always be valid for the appropriate cell types. - OWSAssertDebug(self.viewItem); - - return self.viewItem.hasBodyText; -} - -- (nullable DisplayableText *)displayableBodyText -{ - // This should always be valid for the appropriate cell types. - OWSAssertDebug(self.viewItem.displayableBodyText); - - return self.viewItem.displayableBodyText; -} - -- (TSMessage *)message -{ - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - return (TSMessage *)self.viewItem.interaction; -} - -- (BOOL)isQuotedReply -{ - // This should always be valid for the appropriate cell types. - OWSAssertDebug(self.viewItem); - - return self.viewItem.isQuotedReply; -} - -- (BOOL)hasQuotedText -{ - // This should always be valid for the appropriate cell types. - OWSAssertDebug(self.viewItem); - - return self.viewItem.hasQuotedText; -} - -- (BOOL)isIncoming -{ - return self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage; -} - -- (BOOL)isOutgoing -{ - return self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage; -} - -#pragma mark - Load - -- (void)configureViews -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.viewItem); - OWSAssertDebug(self.viewItem.interaction); - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - NSValue *_Nullable quotedMessageSize = [self quotedMessageSize]; - NSValue *_Nullable bodyMediaSize = [self bodyMediaSize]; - NSValue *_Nullable bodyTextSize = [self bodyTextSize]; - - [self.bubbleView addSubview:self.stackView]; - [self.viewConstraints addObjectsFromArray:[self.stackView autoPinEdgesToSuperviewEdges]]; - NSMutableArray *textViews = [NSMutableArray new]; - - if (self.shouldShowSenderName) { - [self configureSenderNameLabel]; - [textViews addObject:self.senderNameContainer]; - } - - if (self.isQuotedReply) { - // Flush any pending "text" subviews. - BOOL isFirstSubview = ![self insertAnyTextViewsIntoStackView:textViews]; - [textViews removeAllObjects]; - - if (isFirstSubview) { - UIView *spacerView = [UIView containerView]; - [spacerView autoSetDimension:ALDimensionHeight toSize:self.quotedReplyTopMargin]; - [spacerView setCompressionResistanceHigh]; - [self.stackView addArrangedSubview:spacerView]; - } - - DisplayableText *_Nullable displayableQuotedText = [self getDisplayableQuotedText]; - - OWSQuotedMessageView *quotedMessageView = - [OWSQuotedMessageView quotedMessageViewForConversation:self.viewItem.quotedReply - displayableQuotedText:displayableQuotedText - conversationStyle:self.conversationStyle - isOutgoing:self.isOutgoing - sharpCorners:self.sharpCornersForQuotedMessage]; - quotedMessageView.delegate = self; - - self.quotedMessageView = quotedMessageView; - [quotedMessageView createContents]; - [self.stackView addArrangedSubview:quotedMessageView]; - OWSAssertDebug(quotedMessageSize); - [self.viewConstraints addObject:[quotedMessageView autoSetDimension:ALDimensionHeight - toSize:quotedMessageSize.CGSizeValue.height]]; - } - - UIView *_Nullable bodyMediaView = nil; - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - break; - case OWSMessageCellType_Audio: - bodyMediaView = [self loadViewForAudio]; - break; - case OWSMessageCellType_GenericAttachment: - bodyMediaView = [self loadViewForGenericAttachment]; - break; - case OWSMessageCellType_MediaMessage: - bodyMediaView = [self loadViewForMediaAlbum]; - break; - case OWSMessageCellType_OversizeTextDownloading: - bodyMediaView = [self loadViewForOversizeTextDownload]; - break; - } - - if (bodyMediaView) { - OWSAssertDebug(self.loadCellContentBlock); - OWSAssertDebug(self.unloadCellContentBlock); - - bodyMediaView.clipsToBounds = YES; - - self.bodyMediaView = bodyMediaView; - bodyMediaView.userInteractionEnabled = NO; - if (self.hasFullWidthMediaView) { - // Flush any pending "text" subviews. - [self insertAnyTextViewsIntoStackView:textViews]; - [textViews removeAllObjects]; - - if (self.isQuotedReply) { - UIView *spacerView = [UIView containerView]; - [spacerView autoSetDimension:ALDimensionHeight toSize:self.bodyMediaQuotedReplyVSpacing]; - [spacerView setCompressionResistanceHigh]; - [self.stackView addArrangedSubview:spacerView]; - } - - if (self.hasBodyMediaWithThumbnail) { - [self.stackView addArrangedSubview:bodyMediaView]; - } - } else { - [textViews addObject:bodyMediaView]; - } - } - - if (self.viewItem.linkPreview) { - if (self.isQuotedReply) { - UIView *spacerView = [UIView containerView]; - [spacerView autoSetDimension:ALDimensionHeight toSize:self.bodyMediaQuotedReplyVSpacing]; - [spacerView setCompressionResistanceHigh]; - [self.stackView addArrangedSubview:spacerView]; - } - - self.linkPreviewView.state = self.linkPreviewState; - [self.stackView addArrangedSubview:self.linkPreviewView]; - [self.linkPreviewView addBorderViewsWithBubbleView:self.bubbleView]; - } - - // We render malformed messages as "empty text" messages, - // so create a text view if there is no body media view. - if (self.hasBodyText || !bodyMediaView) { - [self configureBodyTextView]; - [textViews addObject:self.bodyTextView]; - - OWSAssertDebug(bodyTextSize); - [self.viewConstraints addObjectsFromArray:@[ - [self.bodyTextView autoSetDimension:ALDimensionHeight toSize:bodyTextSize.CGSizeValue.height], - ]]; - - UIView *_Nullable tapForMoreLabel = [self createTapForMoreLabelIfNecessary]; - if (tapForMoreLabel) { - [textViews addObject:tapForMoreLabel]; - [self.viewConstraints addObjectsFromArray:@[ - [tapForMoreLabel autoSetDimension:ALDimensionHeight toSize:self.tapForMoreHeight], - ]]; - } - } - - BOOL shouldFooterOverlayMedia = (self.canFooterOverlayMedia && bodyMediaView && !self.hasBodyText); - if (self.viewItem.shouldHideFooter) { - // Do nothing. - } else if (shouldFooterOverlayMedia) { - OWSAssertDebug(bodyMediaView); - - CGFloat maxGradientHeight = 40.f; - CAGradientLayer *gradientLayer = [CAGradientLayer new]; - gradientLayer.colors = @[ - (id)[UIColor colorWithWhite:0.0f alpha:0.f].CGColor, - (id)[UIColor colorWithWhite:0.0f alpha:0.4f].CGColor, - ]; - OWSLayerView *gradientView = - [[OWSLayerView alloc] initWithFrame:CGRectZero - layoutCallback:^(UIView *layerView) { - CGRect layerFrame = layerView.bounds; - layerFrame.size.height = MIN(maxGradientHeight, layerView.height); - layerFrame.origin.y = layerView.height - layerFrame.size.height; - gradientLayer.frame = layerFrame; - }]; - [gradientView.layer addSublayer:gradientLayer]; - [bodyMediaView addSubview:gradientView]; - [self.viewConstraints addObjectsFromArray:[gradientView ows_autoPinToSuperviewEdges]]; - - [self.footerView configureWithConversationViewItem:self.viewItem - isOverlayingMedia:YES - conversationStyle:self.conversationStyle - isIncoming:self.isIncoming]; - [bodyMediaView addSubview:self.footerView]; - - bodyMediaView.layoutMargins = UIEdgeInsetsZero; - [self.viewConstraints addObjectsFromArray:@[ - [self.footerView autoPinLeadingToSuperviewMarginWithInset:self.conversationStyle.textInsetHorizontal], - [self.footerView autoPinTrailingToSuperviewMarginWithInset:self.conversationStyle.textInsetHorizontal], - [self.footerView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual], - [self.footerView autoPinBottomToSuperviewMarginWithInset:self.conversationStyle.textInsetBottom], - ]]; - } else { - [self.footerView configureWithConversationViewItem:self.viewItem - isOverlayingMedia:NO - conversationStyle:self.conversationStyle - isIncoming:self.isIncoming]; - [textViews addObject:self.footerView]; - } - - [self insertAnyTextViewsIntoStackView:textViews]; - - CGSize bubbleSize = [self measureSize]; - [self.viewConstraints addObjectsFromArray:@[ - [self autoSetDimension:ALDimensionWidth toSize:bubbleSize.width], - ]]; - if (bodyMediaView) { - OWSAssertDebug(bodyMediaSize); - [self.viewConstraints - addObject:[bodyMediaView autoSetDimension:ALDimensionHeight toSize:bodyMediaSize.CGSizeValue.height]]; - } - - [self updateBubbleColor]; - - [self configureBubbleRounding]; -} - -- (CGFloat)senderNameBottomSpacing -{ - return 0.f; -} - -- (OWSDirectionalRectCorner)sharpCorners -{ - OWSDirectionalRectCorner sharpCorners = 0; - - if (!self.viewItem.isFirstInCluster) { - sharpCorners = sharpCorners - | (self.isIncoming ? OWSDirectionalRectCornerTopLeading : OWSDirectionalRectCornerTopTrailing); - } - - if (!self.viewItem.isLastInCluster) { - sharpCorners = sharpCorners - | (self.isIncoming ? OWSDirectionalRectCornerBottomLeading : OWSDirectionalRectCornerBottomTrailing); - } - - return sharpCorners; -} - -- (OWSDirectionalRectCorner)sharpCornersForQuotedMessage -{ - if (self.viewItem.senderName) { - return OWSDirectionalRectCornerAllCorners; - } else { - return self.sharpCorners | OWSDirectionalRectCornerBottomLeading | OWSDirectionalRectCornerBottomTrailing; - } -} - -- (void)configureBubbleRounding -{ - self.bubbleView.sharpCorners = self.sharpCorners; -} - -- (void)updateBubbleColor -{ - BOOL hasOnlyBodyMediaView = (self.hasBodyMediaWithThumbnail && self.stackView.subviews.count == 1); - if (!hasOnlyBodyMediaView) { - self.bubbleView.bubbleColor = self.bubbleColor; - } else { - // Media-only messages should have no background color; they will fill the bubble's bounds - // and we don't want artifacts at the edges. - self.bubbleView.bubbleColor = nil; - } -} - -- (UIColor *)bubbleColor -{ - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - TSMessage *message = (TSMessage *)self.viewItem.interaction; - return [self.conversationStyle bubbleColorWithMessage:message]; -} - -- (BOOL)hasBodyMediaWithThumbnail -{ - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_Audio: - case OWSMessageCellType_GenericAttachment: - case OWSMessageCellType_OversizeTextDownloading: - return NO; - case OWSMessageCellType_MediaMessage: - return YES; - } -} - -- (BOOL)hasBodyMediaView { - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - return NO; - case OWSMessageCellType_Audio: - case OWSMessageCellType_GenericAttachment: - case OWSMessageCellType_MediaMessage: - case OWSMessageCellType_OversizeTextDownloading: - return YES; - } -} - -- (BOOL)hasFullWidthMediaView -{ - return (self.hasBodyMediaWithThumbnail || self.cellType == OWSMessageCellType_MediaMessage); -} - -- (BOOL)canFooterOverlayMedia -{ - return self.hasBodyMediaWithThumbnail; -} - -- (BOOL)hasBottomFooter -{ - BOOL shouldFooterOverlayMedia = (self.canFooterOverlayMedia && self.hasBodyMediaView && !self.hasBodyText); - if (self.viewItem.shouldHideFooter) { - return NO; - } else if (shouldFooterOverlayMedia) { - return NO; - } else { - return YES; - } -} - -- (BOOL)insertAnyTextViewsIntoStackView:(NSArray *)textViews -{ - if (textViews.count < 1) { - return NO; - } - - UIStackView *textStackView = [[UIStackView alloc] initWithArrangedSubviews:textViews]; - textStackView.axis = UILayoutConstraintAxisVertical; - textStackView.spacing = self.textViewVSpacing; - textStackView.layoutMarginsRelativeArrangement = YES; - textStackView.layoutMargins = UIEdgeInsetsMake(self.conversationStyle.textInsetTop, - self.conversationStyle.textInsetHorizontal, - self.conversationStyle.textInsetBottom, - self.conversationStyle.textInsetHorizontal); - [self.stackView addArrangedSubview:textStackView]; - return YES; -} - -- (CGFloat)textViewVSpacing -{ - return 4.f; -} - -- (CGFloat)bodyMediaQuotedReplyVSpacing -{ - return 4.f; -} - -- (CGFloat)quotedReplyTopMargin -{ - return 16.f; -} - -- (nullable LinkPreviewSent *)linkPreviewState -{ - if (!self.viewItem.linkPreview) { - return nil; - } - return [[LinkPreviewSent alloc] initWithLinkPreview:self.viewItem.linkPreview - imageAttachment:self.viewItem.linkPreviewAttachment - conversationStyle:self.conversationStyle]; -} - -#pragma mark - Load / Unload - -- (void)loadContent -{ - if (self.loadCellContentBlock) { - self.loadCellContentBlock(); - } -} - -- (void)unloadContent -{ - if (self.unloadCellContentBlock) { - self.unloadCellContentBlock(); - } -} - -#pragma mark - Subviews - -- (void)configureBodyTextView -{ - OWSAssertDebug(self.hasBodyText); - - BOOL shouldIgnoreEvents = NO; - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - // Ignore taps on links in outgoing messages that haven't been sent yet, as - // this interferes with "tap to retry". - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - shouldIgnoreEvents = outgoingMessage.messageState != TSOutgoingMessageStateSent; - } - - [self.class loadForTextDisplay:self.bodyTextView - displayableText:self.displayableBodyText - searchText:self.delegate.lastSearchedText - textColor:self.bodyTextColor - font:self.textMessageFont - shouldIgnoreEvents:shouldIgnoreEvents - thread:self.viewItem.interaction.thread - isOutgoingMessage:[self.viewItem.interaction isKindOfClass:TSOutgoingMessage.self]]; -} - -+ (void)loadForTextDisplay:(OWSMessageTextView *)textView - displayableText:(DisplayableText *)displayableText - searchText:(nullable NSString *)searchText - textColor:(UIColor *)textColor - font:(UIFont *)font - shouldIgnoreEvents:(BOOL)shouldIgnoreEvents - thread:(TSThread *)thread - isOutgoingMessage:(BOOL)isOutgoingMessage -{ - textView.hidden = NO; - textView.textColor = textColor; - - textView.font = font; - textView.linkTextAttributes = @{ - NSForegroundColorAttributeName : textColor, - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle | NSUnderlinePatternSolid) - }; - textView.shouldIgnoreEvents = shouldIgnoreEvents; - - NSString *text = displayableText.displayText; - - NSMutableAttributedString *attributedText = [LKMentionUtilities highlightMentionsIn:text isOutgoingMessage:isOutgoingMessage threadID:thread.uniqueId attributes:@{ NSFontAttributeName : font, NSForegroundColorAttributeName : textColor }].mutableCopy; - - if (searchText.length >= ConversationSearchController.kMinimumSearchTextLength) { - NSString *searchableText = [FullTextSearchFinder normalizeWithText:searchText]; - NSError *error; - NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:[NSRegularExpression escapedPatternForString:searchableText] options:NSRegularExpressionCaseInsensitive error:&error]; - OWSAssertDebug(error == nil); - for (NSTextCheckingResult *match in - [regex matchesInString:attributedText.string options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, attributedText.string.length)]) { - OWSAssertDebug(match.range.length >= ConversationSearchController.kMinimumSearchTextLength); - UIColor *highlightColor; - if (LKAppModeUtilities.isLightMode) { - highlightColor = isOutgoingMessage ? UIColor.whiteColor : [LKColors.accent colorWithAlphaComponent:LKValues.unimportantElementOpacity]; - } else { - highlightColor = UIColor.whiteColor; - } - if (match.range.location + match.range.length > attributedText.length) { continue; } - [attributedText addAttribute:NSBackgroundColorAttributeName value:highlightColor range:match.range]; - [attributedText addAttribute:NSForegroundColorAttributeName value:UIColor.blackColor range:match.range]; - } - } - - [textView ensureShouldLinkifyText:displayableText.shouldAllowLinkification]; - - // For perf, set text last. Otherwise changing font/color is more expensive. - - // We use attributedText even when we're not highlighting searched text to esnure any lingering - // attributes are reset. - textView.attributedText = attributedText; -} - -- (BOOL)shouldShowSenderName -{ - return self.viewItem.senderName.length > 0; -} - -- (void)configureSenderNameLabel -{ - OWSAssertDebug(self.senderNameLabel); - OWSAssertDebug(self.shouldShowSenderName); - - self.senderNameLabel.textColor = [LKColors.text colorWithAlphaComponent:LKValues.unimportantElementOpacity]; - self.senderNameLabel.font = OWSMessageBubbleView.senderNameFont; - self.senderNameLabel.text = self.viewItem.senderName.string; - self.senderNameLabel.lineBreakMode = NSLineBreakByTruncatingTail; -} - -+ (UIFont *)senderNameFont -{ - return [UIFont boldSystemFontOfSize:LKValues.smallFontSize]; -} - -+ (NSDictionary *)senderNamePrimaryAttributes -{ - return @{ - NSFontAttributeName : self.senderNameFont, - NSForegroundColorAttributeName : ConversationStyle.bubbleTextColorIncoming, - }; -} - -+ (NSDictionary *)senderNameSecondaryAttributes -{ - return @{ - NSFontAttributeName : self.senderNameFont.ows_italic, - NSForegroundColorAttributeName : ConversationStyle.bubbleTextColorIncoming, - }; -} - -- (BOOL)hasTapForMore -{ - if (!self.hasBodyText) { - return NO; - } else if (!self.displayableBodyText.isTextTruncated) { - return NO; - } else { - return YES; - } -} - -- (nullable UIView *)createTapForMoreLabelIfNecessary -{ - if (!self.hasTapForMore) { - return nil; - } - - UILabel *tapForMoreLabel = [UILabel new]; - tapForMoreLabel.text = NSLocalizedString(@"CONVERSATION_VIEW_OVERSIZE_TEXT_TAP_FOR_MORE", - @"Indicator on truncated text messages that they can be tapped to see the entire text message."); - tapForMoreLabel.font = [self tapForMoreFont]; - tapForMoreLabel.textColor = [self.bodyTextColor colorWithAlphaComponent:0.8]; - tapForMoreLabel.textAlignment = [tapForMoreLabel textAlignmentUnnatural]; - - return tapForMoreLabel; -} - -- (UIView *)loadViewForMediaAlbum -{ - OWSAssertDebug(self.viewItem.mediaAlbumItems); - - OWSMediaAlbumCellView *albumView = - [[OWSMediaAlbumCellView alloc] initWithMediaCache:self.cellMediaCache - items:self.viewItem.mediaAlbumItems - isOutgoing:self.isOutgoing - maxMessageWidth:self.conversationStyle.maxMessageWidth - isOnionRouted:YES]; - self.loadCellContentBlock = ^{ - [albumView loadMedia]; - }; - self.unloadCellContentBlock = ^{ - [albumView unloadMedia]; - }; - - // Only apply "inner shadow" for single media, not albums. - if (albumView.itemViews.count == 1) { - UIView *itemView = albumView.itemViews.firstObject; - OWSBubbleShapeView *innerShadowView = [[OWSBubbleShapeView alloc] - initInnerShadowWithColor:(Theme.isDarkThemeEnabled ? UIColor.ows_whiteColor : UIColor.ows_blackColor) - radius:0.5f - opacity:0.15f]; - [itemView addSubview:innerShadowView]; - [self.bubbleView addPartnerView:innerShadowView]; - [self.viewConstraints addObjectsFromArray:[innerShadowView ows_autoPinToSuperviewEdges]]; - } - - return albumView; -} - -- (UIView *)loadViewForAudio -{ - TSAttachment *attachment = (self.viewItem.attachmentStream ?: self.viewItem.attachmentPointer); - OWSAssertDebug(attachment); - OWSAssertDebug([attachment isAudio]); - - LKVoiceMessageView *voiceMessageView = [[LKVoiceMessageView alloc] initWithVoiceMessage:attachment isOutgoing:self.isOutgoing]; - [voiceMessageView setDuration:(int)self.viewItem.audioDurationSeconds]; - [voiceMessageView setProgress:self.viewItem.audioProgressSeconds / self.viewItem.audioDurationSeconds]; - [voiceMessageView initialize]; - - self.viewItem.lastAudioMessageView = voiceMessageView; - - self.loadCellContentBlock = ^{ - // Do nothing. - }; - self.unloadCellContentBlock = ^{ - // Do nothing. - }; - - return voiceMessageView; -} - -- (UIView *)loadViewForGenericAttachment -{ - TSAttachment *attachment = (self.viewItem.attachmentStream ?: self.viewItem.attachmentPointer); - OWSAssertDebug(attachment); - OWSGenericAttachmentView *attachmentView = [[OWSGenericAttachmentView alloc] initWithAttachment:attachment - isIncoming:self.isIncoming - viewItem:self.viewItem]; - [attachmentView createContentsWithConversationStyle:self.conversationStyle]; - [self addProgressViewsIfNecessary:attachmentView shouldShowDownloadProgress:NO]; - - self.loadCellContentBlock = ^{ - // Do nothing. - }; - self.unloadCellContentBlock = ^{ - // Do nothing. - }; - - return attachmentView; -} - -- (UIView *)loadViewForOversizeTextDownload -{ - // We can use an empty view. The progress views will display download - // progress or tap-to-retry UI. - UIView *attachmentView = [UIView new]; - - [self addProgressViewsIfNecessary:attachmentView shouldShowDownloadProgress:YES]; - - self.loadCellContentBlock = ^{ - // Do nothing. - }; - self.unloadCellContentBlock = ^{ - // Do nothing. - }; - - return attachmentView; -} - -- (void)addProgressViewsIfNecessary:(UIView *)bodyMediaView shouldShowDownloadProgress:(BOOL)shouldShowDownloadProgress -{ - if (self.viewItem.attachmentStream) { - [self addUploadViewIfNecessary:bodyMediaView]; - } else if (self.viewItem.attachmentPointer) { - [self addDownloadViewIfNecessary:bodyMediaView shouldShowDownloadProgress:(BOOL)shouldShowDownloadProgress]; - } -} - -- (void)addUploadViewIfNecessary:(UIView *)bodyMediaView -{ - OWSAssertDebug(self.viewItem.attachmentStream); - - if (!self.isOutgoing) { - return; - } - if (self.viewItem.attachmentStream.isUploaded) { - return; - } - - AttachmentUploadView *uploadView = [[AttachmentUploadView alloc] initWithAttachment:self.viewItem.attachmentStream]; - [self.bubbleView addSubview:uploadView]; - [uploadView autoPinEdgesToSuperviewEdges]; - [uploadView setContentHuggingLow]; - [uploadView setCompressionResistanceLow]; -} - -- (void)addDownloadViewIfNecessary:(UIView *)bodyMediaView shouldShowDownloadProgress:(BOOL)shouldShowDownloadProgress -{ - OWSAssertDebug(self.viewItem.attachmentPointer); - - switch (self.viewItem.attachmentPointer.state) { - case TSAttachmentPointerStateFailed: - [self addTapToRetryView:bodyMediaView]; - return; - case TSAttachmentPointerStateEnqueued: - case TSAttachmentPointerStateDownloading: - break; - } - switch (self.viewItem.attachmentPointer.pointerType) { - case TSAttachmentPointerTypeRestoring: - // TODO: Show "restoring" indicator and possibly progress. - return; - case TSAttachmentPointerTypeUnknown: - case TSAttachmentPointerTypeIncoming: - break; - } - if (!shouldShowDownloadProgress) { - return; - } - NSString *_Nullable uniqueId = self.viewItem.attachmentPointer.uniqueId; - if (uniqueId.length < 1) { - OWSFailDebug(@"Missing uniqueId."); - return; - } - - UIView *overlayView = [UIView new]; - overlayView.backgroundColor = [self.bubbleColor colorWithAlphaComponent:0.5]; - [bodyMediaView addSubview:overlayView]; - [overlayView autoPinEdgesToSuperviewEdges]; - [overlayView setContentHuggingLow]; - [overlayView setCompressionResistanceLow]; - - MediaDownloadView *downloadView = - [[MediaDownloadView alloc] initWithAttachmentId:uniqueId radius:self.conversationStyle.maxMessageWidth * 0.1f]; - bodyMediaView.layer.opacity = 0.5f; - [self.bubbleView addSubview:downloadView]; - [downloadView autoPinEdgesToSuperviewEdges]; - [downloadView setContentHuggingLow]; - [downloadView setCompressionResistanceLow]; -} - -- (void)addTapToRetryView:(UIView *)bodyMediaView -{ - OWSAssertDebug(self.viewItem.attachmentPointer); - - // Hide the body media view, replace with "tap to retry" indicator. - - UILabel *label = [UILabel new]; - label.text = NSLocalizedString( - @"ATTACHMENT_DOWNLOADING_STATUS_FAILED", @"Status label when an attachment download has failed."); - label.font = UIFont.ows_dynamicTypeBodyFont; - label.textColor = Theme.secondaryColor; - label.numberOfLines = 0; - label.lineBreakMode = NSLineBreakByWordWrapping; - label.textAlignment = NSTextAlignmentCenter; - label.backgroundColor = self.bubbleColor; - [bodyMediaView addSubview:label]; - [label autoPinEdgesToSuperviewMargins]; - [label setContentHuggingLow]; - [label setCompressionResistanceLow]; -} - -- (void)showAttachmentErrorViewWithMediaView:(UIView *)mediaView -{ - OWSAssertDebug(mediaView); - - // TODO: We could do a better job of indicating that the media could not be loaded. - UIView *errorView = [UIView new]; - errorView.backgroundColor = [UIColor colorWithWhite:0.85f alpha:1.f]; - errorView.userInteractionEnabled = NO; - [mediaView addSubview:errorView]; - [errorView autoPinEdgesToSuperviewEdges]; -} - -#pragma mark - Measurement - -// Size of "message body" text, not quoted reply text. -- (nullable NSValue *)bodyTextSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.maxMessageWidth > 0); - - if (!self.hasBodyText) { - return nil; - } - - CGFloat hMargins = self.conversationStyle.textInsetHorizontal * 2; - const int maxTextWidth = (int)floor(self.conversationStyle.maxMessageWidth - hMargins); - - [self configureBodyTextView]; - - CGSize result = CGSizeCeil([self.bodyTextView sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); - - return [NSValue valueWithCGSize:CGSizeCeil(result)]; -} - -- (nullable NSValue *)bodyMediaSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.maxMessageWidth > 0); - - // This upper bound should have no effect in portrait orientation. - // It limits body media size in landscape. - const CGFloat kMaxBodyMediaSize = 350; - CGFloat maxMessageWidth = MIN(kMaxBodyMediaSize, self.conversationStyle.maxMessageWidth); - if (!self.hasFullWidthMediaView) { - CGFloat hMargins = self.conversationStyle.textInsetHorizontal * 2; - maxMessageWidth -= hMargins; - } - - CGSize result = CGSizeZero; - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: { - return nil; - } - case OWSMessageCellType_Audio: - result = CGSizeMake(maxMessageWidth, LKVoiceMessageView.contentHeight); - break; - case OWSMessageCellType_GenericAttachment: { - TSAttachment *attachment = (self.viewItem.attachmentStream ?: self.viewItem.attachmentPointer); - OWSAssertDebug(attachment); - OWSGenericAttachmentView *attachmentView = - [[OWSGenericAttachmentView alloc] initWithAttachment:attachment - isIncoming:self.isIncoming - viewItem:self.viewItem]; - [attachmentView createContentsWithConversationStyle:self.conversationStyle]; - result = [attachmentView measureSizeWithMaxMessageWidth:maxMessageWidth]; - break; - } - case OWSMessageCellType_MediaMessage: - result = [OWSMediaAlbumCellView layoutSizeForMaxMessageWidth:maxMessageWidth - items:self.viewItem.mediaAlbumItems]; - - if (self.viewItem.mediaAlbumItems.count == 1) { - // Honor the content aspect ratio for single media. - ConversationMediaAlbumItem *mediaAlbumItem = self.viewItem.mediaAlbumItems.firstObject; - if (mediaAlbumItem.mediaSize.width > 0 && mediaAlbumItem.mediaSize.height > 0) { - CGSize mediaSize = mediaAlbumItem.mediaSize; - CGFloat contentAspectRatio = mediaSize.width / mediaSize.height; - // Clamp the aspect ratio so that very thin/wide content is presented - // in a reasonable way. - const CGFloat minAspectRatio = 0.35f; - const CGFloat maxAspectRatio = 1 / minAspectRatio; - contentAspectRatio = MAX(minAspectRatio, MIN(maxAspectRatio, contentAspectRatio)); - - const CGFloat maxMediaWidth = maxMessageWidth; - const CGFloat maxMediaHeight = maxMessageWidth; - CGFloat mediaWidth = maxMediaHeight * contentAspectRatio; - CGFloat mediaHeight = maxMediaHeight; - if (mediaWidth > maxMediaWidth) { - mediaWidth = maxMediaWidth; - mediaHeight = maxMediaWidth / contentAspectRatio; - } - - // We don't want to blow up small images unnecessarily. - const CGFloat kMinimumSize = 150.f; - CGFloat shortSrcDimension = MIN(mediaSize.width, mediaSize.height); - CGFloat shortDstDimension = MIN(mediaWidth, mediaHeight); - if (shortDstDimension > kMinimumSize && shortDstDimension > shortSrcDimension) { - CGFloat factor = kMinimumSize / shortDstDimension; - mediaWidth *= factor; - mediaHeight *= factor; - } - - result = CGSizeRound(CGSizeMake(mediaWidth, mediaHeight)); - } - } - break; - case OWSMessageCellType_OversizeTextDownloading: - // There's no way to predict the size of the oversize text, - // so we just use a square bubble. - result = CGSizeMake(maxMessageWidth, maxMessageWidth); - break; - } - - OWSAssertDebug(result.width <= maxMessageWidth); - result.width = MIN(result.width, maxMessageWidth); - - return [NSValue valueWithCGSize:CGSizeCeil(result)]; -} - -- (nullable NSValue *)quotedMessageSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.maxMessageWidth > 0); - OWSAssertDebug(self.viewItem); - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - if (!self.isQuotedReply) { - return nil; - } - - DisplayableText *_Nullable displayableQuotedText = [self getDisplayableQuotedText]; - - OWSQuotedMessageView *quotedMessageView = - [OWSQuotedMessageView quotedMessageViewForConversation:self.viewItem.quotedReply - displayableQuotedText:displayableQuotedText - conversationStyle:self.conversationStyle - isOutgoing:self.isOutgoing - sharpCorners:self.sharpCornersForQuotedMessage]; - CGSize result = [quotedMessageView sizeForMaxWidth:self.conversationStyle.maxMessageWidth]; - return [NSValue valueWithCGSize:CGSizeCeil(result)]; -} - -- (DisplayableText *_Nullable)getDisplayableQuotedText -{ - if (!self.viewItem.hasQuotedText) { return nil; } - NSString *rawText = self.viewItem.displayableQuotedText.fullText; - TSThread *thread = self.viewItem.interaction.thread; - NSString *text = [LKMentionUtilities highlightMentionsIn:rawText threadID:thread.uniqueId]; - return [DisplayableText displayableText:text]; -} - -- (nullable NSValue *)senderNameSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.maxMessageWidth > 0); - - if (!self.shouldShowSenderName) { - return nil; - } - - CGFloat hMargins = self.conversationStyle.textInsetHorizontal * 2; - const int maxTextWidth = (int)floor(self.conversationStyle.maxMessageWidth - hMargins); - [self configureSenderNameLabel]; - CGSize result = CGSizeCeil([self.senderNameLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); - result.width = MIN(result.width, maxTextWidth); - result.height += self.senderNameBottomSpacing; - return [NSValue valueWithCGSize:result]; -} - -- (CGSize)measureSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.viewWidth > 0); - OWSAssertDebug(self.viewItem); - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - CGSize cellSize = CGSizeZero; - - [self configureBubbleRounding]; - - NSMutableArray *textViewSizes = [NSMutableArray new]; - - NSValue *_Nullable senderNameSize = [self senderNameSize]; - if (senderNameSize) { - [textViewSizes addObject:senderNameSize]; - } - - NSValue *_Nullable quotedMessageSize = [self quotedMessageSize]; - if (quotedMessageSize) { - if (!senderNameSize) { - cellSize.height += self.quotedReplyTopMargin; - } - cellSize.width = MAX(cellSize.width, quotedMessageSize.CGSizeValue.width); - cellSize.height += quotedMessageSize.CGSizeValue.height; - } - - NSValue *_Nullable bodyMediaSize = [self bodyMediaSize]; - if (bodyMediaSize) { - if (self.hasFullWidthMediaView) { - cellSize.width = MAX(cellSize.width, bodyMediaSize.CGSizeValue.width); - cellSize.height += bodyMediaSize.CGSizeValue.height; - } else { - [textViewSizes addObject:bodyMediaSize]; - bodyMediaSize = nil; - } - } - - if (bodyMediaSize || quotedMessageSize) { - if (textViewSizes.count > 0) { - CGSize groupSize = [self sizeForTextViewGroup:textViewSizes]; - cellSize.width = MAX(cellSize.width, groupSize.width); - cellSize.height += groupSize.height; - [textViewSizes removeAllObjects]; - } - - if (bodyMediaSize && quotedMessageSize && self.hasFullWidthMediaView) { - cellSize.height += self.bodyMediaQuotedReplyVSpacing; - } else if (quotedMessageSize && self.viewItem.linkPreview) { - cellSize.height += self.bodyMediaQuotedReplyVSpacing; - } - } - - if (self.viewItem.linkPreview) { - CGSize linkPreviewSize = [self.linkPreviewView measureWithSentState:self.linkPreviewState]; - linkPreviewSize.width = MIN(linkPreviewSize.width, self.conversationStyle.maxMessageWidth); - cellSize.width = MAX(cellSize.width, linkPreviewSize.width); - cellSize.height += linkPreviewSize.height; - } - - NSValue *_Nullable bodyTextSize = [self bodyTextSize]; - if (bodyTextSize) { - [textViewSizes addObject:bodyTextSize]; - } - - if (self.hasBottomFooter) { - CGSize footerSize = [self.footerView measureWithConversationViewItem:self.viewItem]; - footerSize.width = MIN(footerSize.width, self.conversationStyle.maxMessageWidth); - [textViewSizes addObject:[NSValue valueWithCGSize:footerSize]]; - } - - if (textViewSizes.count > 0) { - CGSize groupSize = [self sizeForTextViewGroup:textViewSizes]; - cellSize.width = MAX(cellSize.width, groupSize.width); - cellSize.height += groupSize.height; - } - - // Make sure the bubble is always wide enough to complete it's bubble shape. - cellSize.width = MAX(cellSize.width, self.bubbleView.minWidth); - - OWSAssertDebug(cellSize.width > 0 && cellSize.height > 0); - - if (self.hasTapForMore) { - cellSize.height += self.tapForMoreHeight + self.textViewVSpacing; - } - - cellSize = CGSizeCeil(cellSize); - - OWSAssertDebug(cellSize.width <= self.conversationStyle.maxMessageWidth); - cellSize.width = MIN(cellSize.width, self.conversationStyle.maxMessageWidth); - - return cellSize; -} - -- (CGSize)sizeForTextViewGroup:(NSArray *)textViewSizes -{ - OWSAssertDebug(textViewSizes); - OWSAssertDebug(textViewSizes.count > 0); - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.maxMessageWidth > 0); - - CGSize result = CGSizeZero; - for (NSValue *size in textViewSizes) { - result.width = MAX(result.width, size.CGSizeValue.width); - result.height += size.CGSizeValue.height; - } - result.height += self.textViewVSpacing * (textViewSizes.count - 1); - result.height += (self.conversationStyle.textInsetTop + self.conversationStyle.textInsetBottom); - result.width += self.conversationStyle.textInsetHorizontal * 2; - - return result; -} - -- (UIFont *)tapForMoreFont -{ - return UIFont.ows_dynamicTypeCaption1Font; -} - -- (CGFloat)tapForMoreHeight -{ - return (CGFloat)ceil([self tapForMoreFont].lineHeight * 1.25); -} - -#pragma mark - - -- (UIColor *)bodyTextColor -{ - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - TSMessage *message = (TSMessage *)self.viewItem.interaction; - return [self.conversationStyle bubbleTextColorWithMessage:message]; -} - -- (void)prepareForReuse -{ - [NSLayoutConstraint deactivateConstraints:self.viewConstraints]; - self.viewConstraints = [NSMutableArray new]; - - self.delegate = nil; - - [self.bodyTextView removeFromSuperview]; - self.bodyTextView.text = nil; - self.bodyTextView.attributedText = nil; - self.bodyTextView.hidden = YES; - - self.bubbleView.bubbleColor = nil; - [self.bubbleView clearPartnerViews]; - - for (UIView *subview in self.bubbleView.subviews) { - [subview removeFromSuperview]; - } - - if (self.unloadCellContentBlock) { - self.unloadCellContentBlock(); - } - self.loadCellContentBlock = nil; - self.unloadCellContentBlock = nil; - - for (UIView *subview in self.bodyMediaView.subviews) { - [subview removeFromSuperview]; - } - [self.bodyMediaView removeFromSuperview]; - self.bodyMediaView = nil; - - [self.quotedMessageView removeFromSuperview]; - self.quotedMessageView = nil; - - [self.footerView removeFromSuperview]; - [self.footerView prepareForReuse]; - - for (UIView *subview in self.stackView.subviews) { - [subview removeFromSuperview]; - } - for (UIView *subview in self.subviews) { - if (subview != self.bubbleView) { - [subview removeFromSuperview]; - } - } - - [self.linkPreviewView removeFromSuperview]; - self.linkPreviewView.state = nil; -} - -#pragma mark - Gestures - -- (void)addTapGestureHandler -{ - UITapGestureRecognizer *tap = - [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)]; - [self addGestureRecognizer:tap]; -} - -- (void)handleTapGesture:(UITapGestureRecognizer *)sender -{ - OWSAssertDebug(self.delegate); - - if (sender.state != UIGestureRecognizerStateRecognized) { - OWSLogVerbose(@"Ignoring tap on message: %@", self.viewItem.interaction.debugDescription); - return; - } - - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) { - return; - } else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) { - // Ignore taps on outgoing messages being sent. - return; - } - } - - CGPoint locationInMessageBubble = [sender locationInView:self]; - switch ([self gestureLocationForLocation:locationInMessageBubble]) { - case OWSMessageGestureLocation_Default: - // Do nothing. - return; - case OWSMessageGestureLocation_OversizeText: - [self.delegate didTapTruncatedTextMessage:self.viewItem]; - return; - case OWSMessageGestureLocation_Media: - [self handleMediaTapGesture:locationInMessageBubble]; - break; - case OWSMessageGestureLocation_QuotedReply: - if (self.viewItem.quotedReply) { - [self.delegate didTapConversationItem:self.viewItem quotedReply:self.viewItem.quotedReply]; - } else { - OWSFailDebug(@"Missing quoted message."); - } - break; - case OWSMessageGestureLocation_LinkPreview: - if (self.viewItem.linkPreview) { - [self.delegate didTapConversationItem:self.viewItem linkPreview:self.viewItem.linkPreview]; - } else { - OWSFailDebug(@"Missing link preview."); - } - break; - } -} - -- (void)handleMediaTapGesture:(CGPoint)locationInMessageBubble -{ - OWSAssertDebug(self.delegate); - - if (self.viewItem.attachmentPointer && self.viewItem.attachmentPointer.state == TSAttachmentPointerStateFailed) { - [self.delegate didTapFailedIncomingAttachment:self.viewItem]; - return; - } - - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextOnlyMessage: - case OWSMessageCellType_OversizeTextDownloading: - break; - case OWSMessageCellType_Audio: - if (self.viewItem.attachmentStream) { - [self.delegate didTapAudioViewItem:self.viewItem attachmentStream:self.viewItem.attachmentStream]; - } - return; - case OWSMessageCellType_GenericAttachment: - if (self.viewItem.attachmentStream) { - [AttachmentSharing showShareUIForAttachment:self.viewItem.attachmentStream]; - } - break; - case OWSMessageCellType_MediaMessage: { - OWSAssertDebug(self.bodyMediaView); - OWSAssertDebug(self.viewItem.mediaAlbumItems.count > 0); - - if (![self.bodyMediaView isKindOfClass:[OWSMediaAlbumCellView class]]) { - OWSFailDebug(@"Unexpected body media view: %@", self.bodyMediaView.class); - return; - } - OWSMediaAlbumCellView *_Nullable mediaAlbumCellView = (OWSMediaAlbumCellView *)self.bodyMediaView; - CGPoint location = [self convertPoint:locationInMessageBubble toView:self.bodyMediaView]; - OWSConversationMediaView *_Nullable mediaView = [mediaAlbumCellView mediaViewForLocation:location]; - if (!mediaView) { - OWSFailDebug(@"Missing media view."); - return; - } - - if ([mediaAlbumCellView isMoreItemsViewWithMediaView:mediaView] - && self.viewItem.mediaAlbumHasFailedAttachment) { - [self.delegate didTapFailedIncomingAttachment:self.viewItem]; - return; - } - - TSAttachment *attachment = mediaView.attachment; - if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { - TSAttachmentPointer *attachmentPointer = (TSAttachmentPointer *)attachment; - if (attachmentPointer.state == TSAttachmentPointerStateFailed) { - // Treat the tap as a "retry" tap if the user taps on a failed download. - [self.delegate didTapFailedIncomingAttachment:self.viewItem]; - return; - } - } else if (![attachment isKindOfClass:[TSAttachmentStream class]]) { - OWSLogWarn(@"Media attachment not yet downloaded."); - return; - } - TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; - [self.delegate didTapImageViewItem:self.viewItem attachmentStream:attachmentStream imageView:mediaView]; - break; - } - } -} - -- (void)handlePanGesture:(UIPanGestureRecognizer *)sender -{ - switch (self.cellType) { - case OWSMessageCellType_Audio: { - LKVoiceMessageView *voiceMessageView = self.viewItem.lastAudioMessageView; - NSTimeInterval currentTime = [voiceMessageView getCurrentTime:sender]; - [self.viewItem setAudioProgress:((CGFloat)currentTime) duration:self.viewItem.audioDurationSeconds]; - CGFloat progress = self.viewItem.audioProgressSeconds / self.viewItem.audioDurationSeconds; - [voiceMessageView setProgress:progress]; - [self.delegate didPanAudioViewItemToCurrentTime:currentTime]; - return; - } - default: return; - } -} - -- (OWSMessageGestureLocation)gestureLocationForLocation:(CGPoint)locationInMessageBubble -{ - if (self.quotedMessageView) { - // Treat this as a "quoted reply" gesture if: - // - // * There is a "quoted reply" view. - // * The gesture occured within or above the "quoted reply" view. - CGPoint location = [self convertPoint:locationInMessageBubble toView:self.quotedMessageView]; - if (location.y <= self.quotedMessageView.height) { - return OWSMessageGestureLocation_QuotedReply; - } - } - - if (self.viewItem.linkPreview) { - CGPoint location = [self convertPoint:locationInMessageBubble toView:self.linkPreviewView]; - if (CGRectContainsPoint(self.linkPreviewView.bounds, location)) { - return OWSMessageGestureLocation_LinkPreview; - } - } - - if (self.bodyMediaView) { - // Treat this as a "body media" gesture if: - // - // * There is a "body media" view. - // * The gesture occured within or above the "body media" view... - // * ...OR if the message doesn't have body text. - CGPoint location = [self convertPoint:locationInMessageBubble toView:self.bodyMediaView]; - if (location.y <= self.bodyMediaView.height) { - return OWSMessageGestureLocation_Media; - } - if (!self.viewItem.hasBodyText) { - return OWSMessageGestureLocation_Media; - } - } - - if (self.hasTapForMore) { - return OWSMessageGestureLocation_OversizeText; - } - - return OWSMessageGestureLocation_Default; -} - -- (void)didTapQuotedReply:(OWSQuotedReplyModel *)quotedReply - failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer -{ - [self.delegate didTapConversationItem:self.viewItem - quotedReply:quotedReply - failedThumbnailDownloadAttachmentPointer:attachmentPointer]; -} - -- (void)didCancelQuotedReply -{ - OWSFailDebug(@"Sent quoted replies should not be cancellable."); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageCell.h b/Session/Conversations/Views & Cells/OWSMessageCell.h deleted file mode 100644 index 4d52718b5..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageCell.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewCell.h" - -@class OWSMessageBubbleView; - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageCell : ConversationViewCell - -@property (nonatomic, readonly) OWSMessageBubbleView *messageBubbleView; - -+ (NSString *)cellReuseIdentifier; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageCell.m b/Session/Conversations/Views & Cells/OWSMessageCell.m deleted file mode 100644 index 7cbafcef3..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageCell.m +++ /dev/null @@ -1,523 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSMessageCell.h" -#import "OWSMessageBubbleView.h" -#import "OWSMessageHeaderView.h" -#import "Session-Swift.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageCell () - -// The nullable properties are created as needed. -// The non-nullable properties are so frequently used that it's easier -// to always keep one around. - -@property (nonatomic) OWSMessageHeaderView *headerView; -@property (nonatomic) OWSMessageBubbleView *messageBubbleView; -@property (nonatomic) NSLayoutConstraint *messageBubbleViewBottomConstraint; -@property (nonatomic) LKProfilePictureView *avatarView; -@property (nonatomic) UIImageView *moderatorIconImageView; -@property (nonatomic, nullable) UIImageView *sendFailureBadgeView; - -@property (nonatomic, nullable) NSMutableArray *viewConstraints; -@property (nonatomic) BOOL isPresentingMenuController; - -@end - -#pragma mark - - -@implementation OWSMessageCell - -// `[UIView init]` invokes `[self initWithFrame:...]`. -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - [self commonInit]; - } - - return self; -} - -- (void)commonInit -{ - // Ensure only called once. - OWSAssertDebug(!self.messageBubbleView); - - self.layoutMargins = UIEdgeInsetsZero; - self.contentView.layoutMargins = UIEdgeInsetsZero; - - _viewConstraints = [NSMutableArray new]; - - self.messageBubbleView = [OWSMessageBubbleView new]; - [self.contentView addSubview:self.messageBubbleView]; - - self.headerView = [OWSMessageHeaderView new]; - - self.avatarView = [[LKProfilePictureView alloc] init]; - [self.avatarView autoSetDimension:ALDimensionWidth toSize:self.avatarSize]; - [self.avatarView autoSetDimension:ALDimensionHeight toSize:self.avatarSize]; - - self.moderatorIconImageView = [[UIImageView alloc] init]; - [self.moderatorIconImageView autoSetDimension:ALDimensionWidth toSize:20.f]; - [self.moderatorIconImageView autoSetDimension:ALDimensionHeight toSize:20.f]; - self.moderatorIconImageView.hidden = YES; - - self.messageBubbleViewBottomConstraint = [self.messageBubbleView autoPinBottomToSuperviewMarginWithInset:0]; - - self.contentView.userInteractionEnabled = YES; - - UITapGestureRecognizer *tap = - [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)]; - [self addGestureRecognizer:tap]; - - UILongPressGestureRecognizer *longPress = - [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGesture:)]; - [self.contentView addGestureRecognizer:longPress]; - - UIPanGestureRecognizer *pan = - [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)]; - pan.delegate = self; - [self.contentView addGestureRecognizer:pan]; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)setConversationStyle:(nullable ConversationStyle *)conversationStyle -{ - [super setConversationStyle:conversationStyle]; - - self.messageBubbleView.conversationStyle = conversationStyle; -} - -+ (NSString *)cellReuseIdentifier -{ - return NSStringFromClass([self class]); -} - -#pragma mark - Convenience Accessors - -- (OWSMessageCellType)cellType -{ - return self.viewItem.messageCellType; -} - -- (TSMessage *)message -{ - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - return (TSMessage *)self.viewItem.interaction; -} - -- (BOOL)isIncoming -{ - return self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage; -} - -- (BOOL)isOutgoing -{ - return self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage; -} - -- (BOOL)shouldHaveSendFailureBadge -{ - if (![self.viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { - return NO; - } - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - return outgoingMessage.messageState == TSOutgoingMessageStateFailed; -} - -#pragma mark - Load - -- (void)loadForDisplay -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.viewItem); - OWSAssertDebug(self.viewItem.interaction); - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - OWSAssertDebug(self.messageBubbleView); - - [self.messageBubbleViewBottomConstraint setActive:YES]; - self.messageBubbleView.viewItem = self.viewItem; - self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache; - [self.messageBubbleView configureViews]; - [self.messageBubbleView loadContent]; - - if (self.viewItem.hasCellHeader) { - CGFloat headerHeight = - [self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle] - .height; - [self.headerView loadForDisplayWithViewItem:self.viewItem conversationStyle:self.conversationStyle]; - [self.contentView addSubview:self.headerView]; - [self.viewConstraints addObjectsFromArray:@[ - [self.headerView autoSetDimension:ALDimensionHeight toSize:headerHeight], - [self.headerView autoPinEdgeToSuperviewEdge:ALEdgeLeading], - [self.headerView autoPinEdgeToSuperviewEdge:ALEdgeTrailing], - [self.headerView autoPinEdgeToSuperviewEdge:ALEdgeTop], - [self.messageBubbleView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.headerView], - ]]; - } else { - [self.viewConstraints addObjectsFromArray:@[ - [self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTop], - ]]; - } - - if (self.isIncoming) { - [self.viewConstraints addObjectsFromArray:@[ - [self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading - withInset:self.conversationStyle.gutterLeading], - [self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTrailing - withInset:self.conversationStyle.gutterTrailing - relation:NSLayoutRelationGreaterThanOrEqual], - ]]; - } else { - if (self.shouldHaveSendFailureBadge) { - self.sendFailureBadgeView = [UIImageView new]; - self.sendFailureBadgeView.image = - [self.sendFailureBadge imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - self.sendFailureBadgeView.tintColor = LKColors.destructive; - [self.contentView addSubview:self.sendFailureBadgeView]; - - CGFloat sendFailureBadgeBottomMargin - = round(self.conversationStyle.lastTextLineAxis - self.sendFailureBadgeSize * 0.5f); - [self.viewConstraints addObjectsFromArray:@[ - [self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading - withInset:self.conversationStyle.gutterLeading - relation:NSLayoutRelationGreaterThanOrEqual], - [self.sendFailureBadgeView autoPinLeadingToTrailingEdgeOfView:self.messageBubbleView - offset:self.sendFailureBadgeSpacing], - // V-align the "send failure" badge with the - // last line of the text (if any, or where it - // would be). - [self.messageBubbleView autoPinEdge:ALEdgeBottom - toEdge:ALEdgeBottom - ofView:self.sendFailureBadgeView - withOffset:sendFailureBadgeBottomMargin], - [self.sendFailureBadgeView autoPinEdgeToSuperviewEdge:ALEdgeTrailing - withInset:self.conversationStyle.errorGutterTrailing], - [self.sendFailureBadgeView autoSetDimension:ALDimensionWidth toSize:self.sendFailureBadgeSize], - [self.sendFailureBadgeView autoSetDimension:ALDimensionHeight toSize:self.sendFailureBadgeSize], - ]]; - } else { - [self.viewConstraints addObjectsFromArray:@[ - [self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading - withInset:self.conversationStyle.gutterLeading - relation:NSLayoutRelationGreaterThanOrEqual], - [self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTrailing - withInset:self.conversationStyle.gutterTrailing], - ]]; - } - } - - if ([self updateAvatarView]) { - [self.viewConstraints addObjectsFromArray:@[ - [self.messageBubbleView autoPinLeadingToTrailingEdgeOfView:self.avatarView offset:12], - [self.messageBubbleView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:self.avatarView], - ]]; - - [self.viewConstraints addObjectsFromArray:@[ - [self.moderatorIconImageView autoPinEdge:ALEdgeTrailing toEdge:ALEdgeTrailing ofView:self.avatarView], - [self.moderatorIconImageView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.avatarView withOffset:3.5] - ]]; - } -} - -- (UIImage *)sendFailureBadge -{ - UIImage *image = [UIImage imageNamed:@"message_status_failed_large"]; - OWSAssertDebug(image); - OWSAssertDebug(image.size.width == self.sendFailureBadgeSize && image.size.height == self.sendFailureBadgeSize); - return image; -} - -- (CGFloat)sendFailureBadgeSize -{ - return 20.f; -} - -- (CGFloat)sendFailureBadgeSpacing -{ - return 8.f; -} - -// * If cell is visible, lazy-load (expensive) view contents. -// * If cell is not visible, eagerly unload view contents. -- (void)ensureMediaLoadState -{ - OWSAssertDebug(self.messageBubbleView); - - if (!self.isCellVisible) { - [self.messageBubbleView unloadContent]; - } else { - [self.messageBubbleView loadContent]; - } -} - -#pragma mark - Avatar - -// Returns YES IFF the avatar view is appropriate and configured. -- (BOOL)updateAvatarView -{ - if (!self.viewItem.shouldShowSenderAvatar) { - return NO; - } - if (!self.viewItem.isGroupThread) { - OWSFailDebug(@"not a group thread."); - return NO; - } - if (self.viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { - OWSFailDebug(@"not an incoming message."); - return NO; - } - - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction; - - [self.contentView addSubview:self.avatarView]; - self.avatarView.size = self.avatarSize; - self.avatarView.hexEncodedPublicKey = incomingMessage.authorId; - [self.avatarView update]; - - // Loki: Show the moderator icon if needed - if (self.viewItem.isGroupThread) { // FIXME: This logic also shouldn't apply to closed groups - SNOpenGroup *publicChat = [LKStorage.shared getOpenGroupForThreadID:self.viewItem.interaction.uniqueThreadId]; - if (publicChat != nil) { - BOOL isModerator = [SNOpenGroupAPI isUserModerator:incomingMessage.authorId forChannel:publicChat.channel onServer:publicChat.server]; - UIImage *moderatorIcon = [UIImage imageNamed:@"Crown"]; - self.moderatorIconImageView.image = moderatorIcon; - self.moderatorIconImageView.hidden = !isModerator; - } - } - - [self.contentView addSubview:self.moderatorIconImageView]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_OtherUsersProfileDidChange - object:nil]; - - return YES; -} - -- (CGFloat)avatarSize -{ - return LKValues.smallProfilePictureSize; -} - -- (void)otherUsersProfileDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - if (!self.viewItem.shouldShowSenderAvatar) { - return; - } - if (!self.viewItem.isGroupThread) { - OWSFailDebug(@"not a group thread."); - return; - } - if (self.viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { - OWSFailDebug(@"not an incoming message."); - return; - } - - NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; - if (recipientId.length == 0) { - return; - } - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction; - - if (![incomingMessage.authorId isEqualToString:recipientId]) { - return; - } - - [self updateAvatarView]; -} - -#pragma mark - Measurement - -- (CGSize)cellSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.conversationStyle.viewWidth > 0); - OWSAssertDebug(self.viewItem); - OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - OWSAssertDebug(self.messageBubbleView); - - self.messageBubbleView.viewItem = self.viewItem; - self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache; - CGSize messageBubbleSize = [self.messageBubbleView measureSize]; - - CGSize cellSize = messageBubbleSize; - - OWSAssertDebug(cellSize.width > 0 && cellSize.height > 0); - - if (self.viewItem.hasCellHeader) { - cellSize.height += - [self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle] - .height; - } - - if (self.shouldHaveSendFailureBadge) { - cellSize.width += self.sendFailureBadgeSize + self.sendFailureBadgeSpacing; - } - - cellSize = CGSizeCeil(cellSize); - - return cellSize; -} - -#pragma mark - Reuse - -- (void)prepareForReuse -{ - [super prepareForReuse]; - - [NSLayoutConstraint deactivateConstraints:self.viewConstraints]; - self.viewConstraints = [NSMutableArray new]; - - [self.messageBubbleView prepareForReuse]; - [self.messageBubbleView unloadContent]; - - [self.headerView removeFromSuperview]; - - [self.avatarView removeFromSuperview]; - - self.moderatorIconImageView.image = nil; - [self.moderatorIconImageView removeFromSuperview]; - - [self.sendFailureBadgeView removeFromSuperview]; - self.sendFailureBadgeView = nil; - - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - Notifications - -- (void)setIsCellVisible:(BOOL)isCellVisible { - BOOL didChange = self.isCellVisible != isCellVisible; - - [super setIsCellVisible:isCellVisible]; - - if (!didChange) { - return; - } - - [self ensureMediaLoadState]; -} - -#pragma mark - Gesture recognizers - -- (void)handleTapGesture:(UITapGestureRecognizer *)sender -{ - OWSAssertDebug(self.delegate); - - if (sender.state != UIGestureRecognizerStateRecognized) { - OWSLogVerbose(@"Ignoring tap on message: %@", self.viewItem.interaction.debugDescription); - return; - } - - if ([self isGestureInCellHeader:sender]) { - return; - } - - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) { - [self.delegate didTapFailedOutgoingMessage:outgoingMessage]; - return; - } else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) { - // Ignore taps on outgoing messages being sent. - return; - } - } - - [self.messageBubbleView handleTapGesture:sender]; -} - -- (void)handleLongPressGesture:(UILongPressGestureRecognizer *)sender -{ - OWSAssertDebug(self.delegate); - - if (sender.state != UIGestureRecognizerStateBegan) { - return; - } - - if ([self isGestureInCellHeader:sender]) { - return; - } - - BOOL shouldAllowReply = YES; - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) { - // Don't allow "delete" or "reply" on "failed" outgoing messages. - shouldAllowReply = NO; - } else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) { - // Don't allow "delete" or "reply" on "sending" outgoing messages. - shouldAllowReply = NO; - } - } - - CGPoint locationInMessageBubble = [sender locationInView:self.messageBubbleView]; - switch ([self.messageBubbleView gestureLocationForLocation:locationInMessageBubble]) { - case OWSMessageGestureLocation_Default: - case OWSMessageGestureLocation_OversizeText: - case OWSMessageGestureLocation_LinkPreview: { - [self.delegate conversationCell:self - shouldAllowReply:shouldAllowReply - didLongpressTextViewItem:self.viewItem]; - break; - } - case OWSMessageGestureLocation_Media: { - [self.delegate conversationCell:self - shouldAllowReply:shouldAllowReply - didLongpressMediaViewItem:self.viewItem]; - break; - } - case OWSMessageGestureLocation_QuotedReply: { - [self.delegate conversationCell:self - shouldAllowReply:shouldAllowReply - didLongpressQuoteViewItem:self.viewItem]; - break; - } - } -} - -- (void)handlePanGesture:(UIPanGestureRecognizer *)sender -{ - [self.messageBubbleView handlePanGesture:sender]; -} - --(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer -{ - LKVoiceMessageView *voiceMessageView = self.viewItem.lastAudioMessageView; - if (![gestureRecognizer isKindOfClass:UIPanGestureRecognizer.class] || voiceMessageView == nil) { return NO; } - UIPanGestureRecognizer *panGestureRecognizer = (UIPanGestureRecognizer *)gestureRecognizer; - CGPoint location = [panGestureRecognizer locationInView:voiceMessageView]; - if (!CGRectContainsPoint(voiceMessageView.bounds, location)) { return NO; } - CGPoint velocity = [panGestureRecognizer velocityInView:voiceMessageView]; - return fabs(velocity.x) > fabs(velocity.y); -} - -- (BOOL)isGestureInCellHeader:(UIGestureRecognizer *)sender -{ - OWSAssertDebug(self.viewItem); - - if (!self.viewItem.hasCellHeader) { - return NO; - } - - CGPoint location = [sender locationInView:self]; - CGPoint headerBottom = [self convertPoint:CGPointMake(0, self.headerView.height) fromView:self.headerView]; - return location.y <= headerBottom.y; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageFooterView.h b/Session/Conversations/Views & Cells/OWSMessageFooterView.h deleted file mode 100644 index 000dc8776..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageFooterView.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; - -@protocol ConversationViewItem; - -@interface OWSMessageFooterView : UIStackView - -- (void)configureWithConversationViewItem:(id)viewItem - isOverlayingMedia:(BOOL)isOverlayingMedia - conversationStyle:(ConversationStyle *)conversationStyle - isIncoming:(BOOL)isIncoming; - -- (CGSize)measureWithConversationViewItem:(id)viewItem; - -- (void)prepareForReuse; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageFooterView.m b/Session/Conversations/Views & Cells/OWSMessageFooterView.m deleted file mode 100644 index 7db924186..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageFooterView.m +++ /dev/null @@ -1,247 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSMessageFooterView.h" -#import "DateUtil.h" -#import "OWSLabel.h" -#import "OWSMessageTimerView.h" -#import "Session-Swift.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageFooterView () - -@property (nonatomic) UILabel *timestampLabel; -@property (nonatomic) UIImageView *statusIndicatorImageView; -@property (nonatomic) OWSMessageTimerView *messageTimerView; - -@end - -@implementation OWSMessageFooterView - -// `[UIView init]` invokes `[self initWithFrame:...]`. -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - [self commontInit]; - } - - return self; -} - -- (void)commontInit -{ - // Ensure only called once. - OWSAssertDebug(!self.timestampLabel); - - self.layoutMargins = UIEdgeInsetsZero; - - self.axis = UILayoutConstraintAxisHorizontal; - self.alignment = UIStackViewAlignmentCenter; - self.distribution = UIStackViewDistributionEqualSpacing; - - UIStackView *leftStackView = [UIStackView new]; - leftStackView.axis = UILayoutConstraintAxisHorizontal; - leftStackView.spacing = self.hSpacing; - leftStackView.alignment = UIStackViewAlignmentCenter; - [self addArrangedSubview:leftStackView]; - [leftStackView setContentHuggingHigh]; - - self.timestampLabel = [OWSLabel new]; - [leftStackView addArrangedSubview:self.timestampLabel]; - - self.messageTimerView = [OWSMessageTimerView new]; - [self.messageTimerView setContentHuggingHigh]; - [leftStackView addArrangedSubview:self.messageTimerView]; - - self.statusIndicatorImageView = [UIImageView new]; - - self.userInteractionEnabled = NO; -} - -- (void)configureFonts -{ - self.timestampLabel.font = [UIFont systemFontOfSize:LKValues.verySmallFontSize]; -} - -- (CGFloat)hSpacing -{ - // TODO: Review constant. - return 8.f; -} - -- (CGFloat)maxImageWidth -{ - return 18.f; -} - -- (CGFloat)imageHeight -{ - return 12.f; -} - -#pragma mark - Load - -- (void)configureWithConversationViewItem:(id)viewItem - isOverlayingMedia:(BOOL)isOverlayingMedia - conversationStyle:(ConversationStyle *)conversationStyle - isIncoming:(BOOL)isIncoming -{ - OWSAssertDebug(viewItem); - OWSAssertDebug(conversationStyle); - - [self configureLabelsWithConversationViewItem:viewItem]; - - UIColor *textColor; - if (isOverlayingMedia) { - textColor = UIColor.whiteColor; - } else { - textColor = [conversationStyle bubbleSecondaryTextColorWithIsIncoming:isIncoming]; - } - self.timestampLabel.textColor = textColor; - - if (viewItem.isExpiringMessage) { - TSMessage *message = (TSMessage *)viewItem.interaction; - uint64_t expirationTimestamp = message.expiresAt; - uint32_t expiresInSeconds = message.expiresInSeconds; - [self.messageTimerView configureWithExpirationTimestamp:expirationTimestamp - initialDurationSeconds:expiresInSeconds - tintColor:textColor]; - self.messageTimerView.hidden = NO; - } else { - self.messageTimerView.hidden = YES; - } - - if (viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - UIImage *_Nullable statusIndicatorImage = nil; - - __block BOOL isNoteToSelf = NO; - [OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - TSContactThread *thread = [outgoingMessage.thread as:TSContactThread.class]; - if (thread != nil) { - NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; - isNoteToSelf = ([thread.contactIdentifier isEqual:userPublicKey]); - } - }]; - - if (statusIndicatorImage && !isNoteToSelf) { - [self showStatusIndicatorWithIcon:statusIndicatorImage textColor:textColor]; - } else { - [self hideStatusIndicator]; - } - } else { - [self hideStatusIndicator]; - } -} - -- (void)showStatusIndicatorWithIcon:(UIImage *)icon textColor:(UIColor *)textColor -{ - OWSAssertDebug(icon.size.width <= self.maxImageWidth); - self.statusIndicatorImageView.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - self.statusIndicatorImageView.tintColor = textColor; - [self.statusIndicatorImageView setContentHuggingHigh]; - self.spacing = self.hSpacing; -} - -- (void)hideStatusIndicator -{ - // If there's no status indicator, we want the other - // footer contents to "cling to the leading edge". - // Instead of hiding the status indicator view, - // we clear its contents and let it stretch to fill - // the available space. - self.statusIndicatorImageView.image = nil; - [self.statusIndicatorImageView setContentHuggingLow]; - self.spacing = 0; -} - -- (void)animateSpinningIcon -{ - CABasicAnimation *animation; - animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"]; - animation.toValue = @(M_PI * 2.0); - const CGFloat kPeriodSeconds = 1.f; - animation.duration = kPeriodSeconds; - animation.cumulative = YES; - animation.repeatCount = HUGE_VALF; - - [self.statusIndicatorImageView.layer addAnimation:animation forKey:@"animation"]; -} - -- (BOOL)isFailedOutgoingMessage:(id)viewItem -{ - OWSAssertDebug(viewItem); - - if (viewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage) { - return NO; - } - - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - MessageReceiptStatus messageStatus = - [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:outgoingMessage]; - return messageStatus == MessageReceiptStatusFailed; -} - -- (void)configureLabelsWithConversationViewItem:(id)viewItem -{ - OWSAssertDebug(viewItem); - - [self configureFonts]; - - NSString *timestampLabelText; - if ([self isFailedOutgoingMessage:viewItem]) { - timestampLabelText - = NSLocalizedString(@"MESSAGE_STATUS_SEND_FAILED", @"Label indicating that a message failed to send."); - } else { - timestampLabelText = [DateUtil formatMessageTimestamp:viewItem.interaction.timestampForUI]; - } - - [self.timestampLabel setText:timestampLabelText.localizedUppercaseString]; -} - -- (CGSize)measureWithConversationViewItem:(id)viewItem -{ - OWSAssertDebug(viewItem); - - [self configureLabelsWithConversationViewItem:viewItem]; - - CGSize result = CGSizeZero; - result.height = MAX(self.timestampLabel.font.lineHeight, self.imageHeight); - - // Measure the actual current width, to be safe. - CGFloat timestampLabelWidth = [self.timestampLabel sizeThatFits:CGSizeZero].width; - - result.width = timestampLabelWidth; - - if (viewItem.isExpiringMessage) { - result.width += ([OWSMessageTimerView measureSize].width + self.hSpacing); - } - - return CGSizeCeil(result); -} - -- (nullable NSString *)messageStatusTextForConversationViewItem:(id)viewItem -{ - OWSAssertDebug(viewItem); - if (viewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage) { - return nil; - } - - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - NSString *statusMessage = [MessageRecipientStatusUtils receiptMessageWithOutgoingMessage:outgoingMessage]; - return statusMessage; -} - -- (void)prepareForReuse -{ - [self.statusIndicatorImageView.layer removeAllAnimations]; - - [self.messageTimerView prepareForReuse]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageHeaderView.h b/Session/Conversations/Views & Cells/OWSMessageHeaderView.h deleted file mode 100644 index 2b5cb8e56..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageHeaderView.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -extern const CGFloat OWSMessageHeaderViewDateHeaderVMargin; - -@class ConversationStyle; - -@protocol ConversationViewItem; - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageHeaderView : UIStackView - -- (void)loadForDisplayWithViewItem:(id)viewItem - conversationStyle:(ConversationStyle *)conversationStyle; - -- (CGSize)measureWithConversationViewItem:(id)viewItem - conversationStyle:(ConversationStyle *)conversationStyle; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageHeaderView.m b/Session/Conversations/Views & Cells/OWSMessageHeaderView.m deleted file mode 100644 index 3b9ad7235..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageHeaderView.m +++ /dev/null @@ -1,196 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSMessageHeaderView.h" -#import "ConversationViewItem.h" -#import "Session-Swift.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -const CGFloat OWSMessageHeaderViewDateHeaderVMargin = 16; // Values.mediumSpacing - -@interface OWSMessageHeaderView () - -@property (nonatomic) UILabel *titleLabel; -@property (nonatomic) UILabel *subtitleLabel; -@property (nonatomic) UIView *strokeView; -@property (nonatomic) NSArray *layoutConstraints; -@property (nonatomic) UIStackView *stackView; - -@end - -#pragma mark - - -@implementation OWSMessageHeaderView - -// `[UIView init]` invokes `[self initWithFrame:...]`. -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - [self commontInit]; - } - - return self; -} - -- (void)commontInit -{ - OWSAssertDebug(!self.titleLabel); - - self.layoutMargins = UIEdgeInsetsZero; - self.layoutConstraints = @[]; - - // Intercept touches. - // Date breaks and unread indicators are not interactive. - self.userInteractionEnabled = YES; - - self.strokeView = [UIView new]; - [self.strokeView setContentHuggingHigh]; - - self.titleLabel = [UILabel new]; - self.titleLabel.textAlignment = NSTextAlignmentCenter; - self.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; - self.titleLabel.textColor = [LKColors.text colorWithAlphaComponent:0.8]; - - self.subtitleLabel = [UILabel new]; - // The subtitle may wrap to a second line. - self.subtitleLabel.numberOfLines = 0; - self.subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping; - self.subtitleLabel.textAlignment = NSTextAlignmentCenter; - - self.stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ - self.strokeView, - self.titleLabel, - self.subtitleLabel, - ]]; - self.stackView.axis = NSTextLayoutOrientationVertical; - self.stackView.spacing = 2; - [self addSubview:self.stackView]; -} - -- (void)loadForDisplayWithViewItem:(id)viewItem - conversationStyle:(ConversationStyle *)conversationStyle -{ - OWSAssertDebug(viewItem); - OWSAssertDebug(conversationStyle); - OWSAssertDebug(viewItem.unreadIndicator || viewItem.shouldShowDate); - - self.titleLabel.textColor = [LKColors.text colorWithAlphaComponent:0.8]; - self.subtitleLabel.textColor = [LKColors.text colorWithAlphaComponent:0.8]; - - [self configureLabelsWithViewItem:viewItem]; - - CGFloat strokeThickness = [self strokeThicknessWithViewItem:viewItem]; - self.strokeView.layer.cornerRadius = strokeThickness * 0.5f; - self.strokeView.backgroundColor = [self strokeColorWithViewItem:viewItem]; - self.strokeView.hidden = viewItem.unreadIndicator == nil; - - self.subtitleLabel.hidden = self.subtitleLabel.text.length < 1; - - [NSLayoutConstraint deactivateConstraints:self.layoutConstraints]; - self.layoutConstraints = @[ - [self.strokeView autoSetDimension:ALDimensionHeight toSize:strokeThickness], - - [self.stackView autoPinEdgeToSuperviewEdge:ALEdgeTop], - [self.stackView autoPinEdgeToSuperviewEdge:ALEdgeLeading withInset:conversationStyle.headerGutterLeading], - [self.stackView autoPinEdgeToSuperviewEdge:ALEdgeTrailing withInset:conversationStyle.headerGutterTrailing] - ]; -} - -- (CGFloat)strokeThicknessWithViewItem:(id)viewItem -{ - OWSAssertDebug(viewItem); - - if (viewItem.unreadIndicator) { - return 1.f; - } else { - return 0.f; - } -} - -- (UIColor *)strokeColorWithViewItem:(id)viewItem -{ - OWSAssertDebug(viewItem); - - if (viewItem.unreadIndicator) { - return Theme.secondaryColor; - } else { - return Theme.hairlineColor; - } -} - -- (void)configureLabelsWithViewItem:(id)viewItem -{ - OWSAssertDebug(viewItem); - - NSDate *date = viewItem.interaction.receivedAtDate; - NSString *dateString = [DateUtil formatDateForConversationDateBreaks:date]; - - // Update cell to reflect changes in dynamic text. - if (viewItem.unreadIndicator) { - self.titleLabel.font = [UIFont boldSystemFontOfSize:LKValues.verySmallFontSize]; - - NSString *title = NSLocalizedString( - @"MESSAGES_VIEW_UNREAD_INDICATOR", @"Indicator that separates read from unread messages."); - if (viewItem.shouldShowDate) { - title = [[dateString rtlSafeAppend:@" \u00B7 "] rtlSafeAppend:title]; - } - self.titleLabel.text = title; - - if (!viewItem.unreadIndicator.hasMoreUnseenMessages) { - self.subtitleLabel.text = nil; - } else { - self.subtitleLabel.text = (viewItem.unreadIndicator.missingUnseenSafetyNumberChangeCount > 0 - ? NSLocalizedString(@"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES", - @"Messages that indicates that there are more unseen messages.") - : NSLocalizedString( - @"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES_AND_SAFETY_NUMBER_CHANGES", - @"Messages that indicates that there are more unseen messages including safety number " - @"changes.")); - } - } else { - self.titleLabel.font = [UIFont boldSystemFontOfSize:LKValues.verySmallFontSize]; - self.titleLabel.text = dateString; - self.subtitleLabel.text = nil; - } -} - -- (CGSize)measureWithConversationViewItem:(id)viewItem - conversationStyle:(ConversationStyle *)conversationStyle -{ - OWSAssertDebug(viewItem); - OWSAssertDebug(conversationStyle); - OWSAssertDebug(viewItem.unreadIndicator || viewItem.shouldShowDate); - - [self configureLabelsWithViewItem:viewItem]; - - CGSize result = CGSizeMake(conversationStyle.viewWidth, 0); - - CGFloat strokeThickness = [self strokeThicknessWithViewItem:viewItem]; - result.height += strokeThickness; - - if (strokeThickness != 0) { - result.height += self.stackView.spacing; - } - - CGFloat maxTextWidth = conversationStyle.headerViewContentWidth; - CGSize titleSize = [self.titleLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]; - result.height += titleSize.height; - - if (self.subtitleLabel.text.length > 0) { - CGSize subtitleSize = [self.subtitleLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]; - result.height += self.stackView.spacing + subtitleSize.height; - } - result.height += OWSMessageHeaderViewDateHeaderVMargin; - - return CGSizeCeil(result); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageTextView.h b/Session/Conversations/Views & Cells/OWSMessageTextView.h deleted file mode 100644 index cb1691f3f..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageTextView.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageTextView : OWSTextView - -@property (nonatomic) BOOL shouldIgnoreEvents; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSMessageTextView.m b/Session/Conversations/Views & Cells/OWSMessageTextView.m deleted file mode 100644 index c6a318b85..000000000 --- a/Session/Conversations/Views & Cells/OWSMessageTextView.m +++ /dev/null @@ -1,129 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSMessageTextView.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSMessageTextView () - -@property (nonatomic, nullable) NSValue *cachedSize; - -@end - -#pragma mark - - -@implementation OWSMessageTextView - -// Our message text views are never used for editing; -// suppress their ability to become first responder -// so that tapping on them doesn't hide keyboard. -- (BOOL)canBecomeFirstResponder -{ - return NO; -} - -// Ignore interactions with the text view _except_ taps on links. -// -// We want to disable "partial" selection of text in the message -// and we want to enable "tap to resend" by tapping on a message. -- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *_Nullable)event -{ - if (self.shouldIgnoreEvents) { - // We ignore all events for failed messages so that users - // can tap-to-resend even "all link" messages. - return NO; - } - - // Find the nearest text position to the event. - UITextPosition *_Nullable position = [self closestPositionToPoint:point]; - if (!position) { - return NO; - } - // Find the range of the character in the text which contains the event. - // - // Try every layout direction (this might not be necessary). - UITextRange *_Nullable range = nil; - for (NSNumber *textLayoutDirection in @[ - @(UITextLayoutDirectionLeft), - @(UITextLayoutDirectionRight), - @(UITextLayoutDirectionUp), - @(UITextLayoutDirectionDown), - ]) { - range = [self.tokenizer rangeEnclosingPosition:position - withGranularity:UITextGranularityCharacter - inDirection:(UITextDirection)textLayoutDirection.intValue]; - if (range) { - break; - } - } - if (!range) { - return NO; - } - // Ignore the event unless it occurred inside a link. - NSInteger startIndex = [self offsetFromPosition:self.beginningOfDocument toPosition:range.start]; - BOOL result = - [self.attributedText attribute:NSLinkAttributeName atIndex:(NSUInteger)startIndex effectiveRange:nil] != nil; - return result; -} - -- (void)setText:(nullable NSString *)text -{ - if ([NSObject isNullableObject:text equalTo:self.text]) { - return; - } - [super setText:text]; - self.cachedSize = nil; -} - -- (void)setAttributedText:(nullable NSAttributedString *)attributedText -{ - if ([NSObject isNullableObject:attributedText equalTo:self.attributedText]) { - return; - } - [super setAttributedText:attributedText]; - self.cachedSize = nil; -} - -- (void)setTextColor:(nullable UIColor *)textColor -{ - if ([NSObject isNullableObject:textColor equalTo:self.textColor]) { - return; - } - [super setTextColor:textColor]; - // No need to clear cached size here. -} - -- (void)setFont:(nullable UIFont *)font -{ - if ([NSObject isNullableObject:font equalTo:self.font]) { - return; - } - [super setFont:font]; - self.cachedSize = nil; -} - -- (void)setLinkTextAttributes:(nullable NSDictionary *)linkTextAttributes -{ - if ([NSObject isNullableObject:linkTextAttributes equalTo:self.linkTextAttributes]) { - return; - } - [super setLinkTextAttributes:linkTextAttributes]; - self.cachedSize = nil; -} - -- (CGSize)sizeThatFits:(CGSize)size -{ - if (self.cachedSize) { - return self.cachedSize.CGSizeValue; - } - CGSize result = [super sizeThatFits:size]; - self.cachedSize = [NSValue valueWithCGSize:result]; - return result; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSQuotedMessageView.h b/Session/Conversations/Views & Cells/OWSQuotedMessageView.h deleted file mode 100644 index 5065a9df9..000000000 --- a/Session/Conversations/Views & Cells/OWSQuotedMessageView.h +++ /dev/null @@ -1,50 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSBubbleView.h" - -NS_ASSUME_NONNULL_BEGIN - -@class ConversationStyle; -@class DisplayableText; -@class OWSBubbleShapeView; -@class OWSQuotedReplyModel; -@class TSAttachmentPointer; -@class TSQuotedMessage; - -@protocol OWSQuotedMessageViewDelegate - -- (void)didTapQuotedReply:(OWSQuotedReplyModel *)quotedReply - failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer; - -- (void)didCancelQuotedReply; - -@end - -@interface OWSQuotedMessageView : UIView - -@property (nonatomic, nullable, weak) id delegate; - -- (instancetype)init NS_UNAVAILABLE; - -// Only needs to be called if we're going to render this instance. -- (void)createContents; - -// Measurement -- (CGSize)sizeForMaxWidth:(CGFloat)maxWidth; - -// Factory method for "message bubble" views. -+ (OWSQuotedMessageView *)quotedMessageViewForConversation:(OWSQuotedReplyModel *)quotedMessage - displayableQuotedText:(nullable DisplayableText *)displayableQuotedText - conversationStyle:(ConversationStyle *)conversationStyle - isOutgoing:(BOOL)isOutgoing - sharpCorners:(OWSDirectionalRectCorner)sharpCorners; - -// Factory method for "message compose" views. -+ (OWSQuotedMessageView *)quotedMessageViewForPreview:(OWSQuotedReplyModel *)quotedMessage - conversationStyle:(ConversationStyle *)conversationStyle; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSQuotedMessageView.m b/Session/Conversations/Views & Cells/OWSQuotedMessageView.m deleted file mode 100644 index 604363c7f..000000000 --- a/Session/Conversations/Views & Cells/OWSQuotedMessageView.m +++ /dev/null @@ -1,692 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSQuotedMessageView.h" -#import "ConversationViewItem.h" -#import "Environment.h" -#import "OWSBubbleView.h" -#import "Session-Swift.h" -#import - -#import -#import -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -const CGFloat kRemotelySourcedContentGlyphLength = 16; -const CGFloat kRemotelySourcedContentRowMargin = 4; -const CGFloat kRemotelySourcedContentRowSpacing = 4; - -@interface OWSQuotedMessageView () - -@property (nonatomic, readonly) OWSQuotedReplyModel *quotedMessage; -@property (nonatomic, nullable, readonly) DisplayableText *displayableQuotedText; -@property (nonatomic, readonly) ConversationStyle *conversationStyle; - -@property (nonatomic, readonly) BOOL isForPreview; -@property (nonatomic, readonly) BOOL isOutgoing; -@property (nonatomic, readonly) OWSDirectionalRectCorner sharpCorners; - -@property (nonatomic, readonly) UILabel *quotedAuthorLabel; -@property (nonatomic, readonly) UILabel *quotedTextLabel; -@property (nonatomic, readonly) UILabel *quoteContentSourceLabel; - -@end - -#pragma mark - - -@implementation OWSQuotedMessageView - -+ (OWSQuotedMessageView *)quotedMessageViewForConversation:(OWSQuotedReplyModel *)quotedMessage - displayableQuotedText:(nullable DisplayableText *)displayableQuotedText - conversationStyle:(ConversationStyle *)conversationStyle - isOutgoing:(BOOL)isOutgoing - sharpCorners:(OWSDirectionalRectCorner)sharpCorners -{ - OWSAssertDebug(quotedMessage); - - return [[OWSQuotedMessageView alloc] initWithQuotedMessage:quotedMessage - displayableQuotedText:displayableQuotedText - conversationStyle:conversationStyle - isForPreview:NO - isOutgoing:isOutgoing - sharpCorners:sharpCorners]; -} - -+ (OWSQuotedMessageView *)quotedMessageViewForPreview:(OWSQuotedReplyModel *)quotedMessage - conversationStyle:(ConversationStyle *)conversationStyle -{ - OWSAssertDebug(quotedMessage); - - DisplayableText *_Nullable displayableQuotedText = nil; - if (quotedMessage.body.length > 0) { - displayableQuotedText = [DisplayableText displayableText:quotedMessage.body]; - } - - OWSQuotedMessageView *instance = [[OWSQuotedMessageView alloc] - initWithQuotedMessage:quotedMessage - displayableQuotedText:displayableQuotedText - conversationStyle:conversationStyle - isForPreview:YES - isOutgoing:YES - sharpCorners:(OWSDirectionalRectCornerBottomLeading | OWSDirectionalRectCornerBottomTrailing)]; - [instance createContents]; - return instance; -} - -- (instancetype)initWithQuotedMessage:(OWSQuotedReplyModel *)quotedMessage - displayableQuotedText:(nullable DisplayableText *)displayableQuotedText - conversationStyle:(ConversationStyle *)conversationStyle - isForPreview:(BOOL)isForPreview - isOutgoing:(BOOL)isOutgoing - sharpCorners:(OWSDirectionalRectCorner)sharpCorners -{ - self = [super init]; - - if (!self) { - return self; - } - - OWSAssertDebug(quotedMessage); - - _quotedMessage = quotedMessage; - _displayableQuotedText = displayableQuotedText; - _isForPreview = isForPreview; - _conversationStyle = conversationStyle; - _isOutgoing = isOutgoing; - _sharpCorners = sharpCorners; - - _quotedAuthorLabel = [UILabel new]; - _quotedTextLabel = [UILabel new]; - _quoteContentSourceLabel = [UILabel new]; - - return self; -} - -- (BOOL)hasQuotedAttachment -{ - return (self.quotedMessage.contentType.length > 0 - && ![OWSMimeTypeOversizeTextMessage isEqualToString:self.quotedMessage.contentType]); -} - -- (BOOL)hasQuotedAttachmentThumbnailImage -{ - return (self.quotedMessage.contentType.length > 0 - && ![OWSMimeTypeOversizeTextMessage isEqualToString:self.quotedMessage.contentType] && - [TSAttachmentStream hasThumbnailForMimeType:self.quotedMessage.contentType]); -} - -- (UIColor *)highlightColor -{ - BOOL isQuotingSelf = [NSObject isNullableObject:self.quotedMessage.authorId equalTo:TSAccountManager.localNumber]; - return (isQuotingSelf ? [self.conversationStyle bubbleColorWithIsIncoming:NO] - : [self.conversationStyle quotingSelfHighlightColor]); -} - -#pragma mark - - -- (CGFloat)bubbleHMargin -{ - return (self.isForPreview ? 0.f : 20.f); -} - -- (CGFloat)hSpacing -{ - return 8.f; -} - -- (CGFloat)vSpacing -{ - return 4.f; -} - -- (CGFloat)stripeThickness -{ - return LKValues.accentLineThickness; -} - -- (UIColor *)quoteBubbleBackgroundColor -{ - return [self.conversationStyle quotedReplyBubbleColorWithIsIncoming:!self.isOutgoing]; -} - -- (void)createContents -{ - // Ensure only called once. - OWSAssertDebug(self.subviews.count < 1); - - self.userInteractionEnabled = YES; - self.layoutMargins = UIEdgeInsetsZero; - self.clipsToBounds = YES; - - CAShapeLayer *maskLayer = [CAShapeLayer new]; - OWSDirectionalRectCorner sharpCorners = self.sharpCorners; - - OWSLayerView *innerBubbleView = [[OWSLayerView alloc] - initWithFrame:CGRectZero - layoutCallback:^(UIView *layerView) { - CGRect layerFrame = layerView.bounds; - - const CGFloat bubbleLeft = 0.f; - const CGFloat bubbleRight = layerFrame.size.width; - const CGFloat bubbleTop = 0.f; - const CGFloat bubbleBottom = layerFrame.size.height; - - const CGFloat sharpCornerRadius = 2; - const CGFloat wideCornerRadius = self.isForPreview ? 14 : 4; - - UIBezierPath *bezierPath = [OWSBubbleView roundedBezierRectWithBubbleTop:bubbleTop - bubbleLeft:bubbleLeft - bubbleBottom:bubbleBottom - bubbleRight:bubbleRight - sharpCornerRadius:sharpCornerRadius - wideCornerRadius:wideCornerRadius - sharpCorners:sharpCorners]; - - maskLayer.path = bezierPath.CGPath; - }]; - innerBubbleView.layer.mask = maskLayer; - if (self.isForPreview) { - NSString *userHexEncodedPublicKey = [SNGeneralUtilities getUserPublicKey]; - BOOL wasSentByUser = [self.quotedMessage.authorId isEqual:userHexEncodedPublicKey]; - innerBubbleView.backgroundColor = [self.conversationStyle quotedReplyBubbleColorWithIsIncoming:wasSentByUser]; - } else { - innerBubbleView.backgroundColor = self.quoteBubbleBackgroundColor; - } - [self addSubview:innerBubbleView]; - [innerBubbleView autoPinLeadingToSuperviewMarginWithInset:self.bubbleHMargin]; - [innerBubbleView autoPinTrailingToSuperviewMarginWithInset:self.bubbleHMargin]; - [innerBubbleView autoPinTopToSuperviewMargin]; - [innerBubbleView autoPinBottomToSuperviewMargin]; - [innerBubbleView setContentHuggingHorizontalLow]; - [innerBubbleView setCompressionResistanceHorizontalLow]; - - UIStackView *hStackView = [UIStackView new]; - hStackView.axis = UILayoutConstraintAxisHorizontal; - hStackView.spacing = self.hSpacing; - - UIView *stripeView = [UIView new]; - if (self.isForPreview) { - stripeView.backgroundColor = LKColors.accent; - } else { - stripeView.backgroundColor = [self.conversationStyle quotedReplyStripeColorWithIsIncoming:!self.isOutgoing]; - } - [stripeView autoSetDimension:ALDimensionWidth toSize:self.stripeThickness]; - [stripeView setContentHuggingHigh]; - [stripeView setCompressionResistanceHigh]; - [hStackView addArrangedSubview:stripeView]; - - UIStackView *vStackView = [UIStackView new]; - vStackView.axis = UILayoutConstraintAxisVertical; - vStackView.layoutMargins = UIEdgeInsetsMake(self.textVMargin, 0, self.textVMargin, 0); - vStackView.layoutMarginsRelativeArrangement = YES; - vStackView.spacing = self.vSpacing; - [vStackView setContentHuggingHorizontalLow]; - [vStackView setCompressionResistanceHorizontalLow]; - [hStackView addArrangedSubview:vStackView]; - - UILabel *quotedAuthorLabel = [self configureQuotedAuthorLabel]; - [vStackView addArrangedSubview:quotedAuthorLabel]; - [quotedAuthorLabel autoSetDimension:ALDimensionHeight toSize:self.quotedAuthorHeight]; - [quotedAuthorLabel setContentHuggingVerticalHigh]; - [quotedAuthorLabel setContentHuggingHorizontalLow]; - [quotedAuthorLabel setCompressionResistanceHorizontalLow]; - - UILabel *quotedTextLabel = [self configureQuotedTextLabel]; - [vStackView addArrangedSubview:quotedTextLabel]; - [quotedTextLabel setContentHuggingHorizontalLow]; - [quotedTextLabel setCompressionResistanceHorizontalLow]; - [quotedTextLabel setCompressionResistanceVerticalHigh]; - - if (self.hasQuotedAttachment) { - UIView *_Nullable quotedAttachmentView = nil; - UIImage *_Nullable thumbnailImage = [self tryToLoadThumbnailImage]; - if (thumbnailImage) { - quotedAttachmentView = [self imageViewForImage:thumbnailImage]; - quotedAttachmentView.clipsToBounds = YES; - quotedAttachmentView.backgroundColor = [UIColor whiteColor]; - - if (self.isVideoAttachment) { - UIImage *contentIcon = [UIImage imageNamed:@"Play"]; - contentIcon = [contentIcon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - UIImageView *contentImageView = [self imageViewForImage:contentIcon]; - contentImageView.tintColor = LKColors.text; - [quotedAttachmentView addSubview:contentImageView]; - [contentImageView autoSetDimension:ALDimensionWidth toSize:16]; - [contentImageView autoSetDimension:ALDimensionHeight toSize:16]; - [contentImageView autoCenterInSuperview]; - } - } else if (self.quotedMessage.thumbnailDownloadFailed) { - // TODO design review icon and color - UIImage *contentIcon = - [[UIImage imageNamed:@"btnRefresh--white"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - UIImageView *contentImageView = [self imageViewForImage:contentIcon]; - contentImageView.contentMode = UIViewContentModeScaleAspectFit; - contentImageView.tintColor = UIColor.whiteColor; - - quotedAttachmentView = [UIView containerView]; - [quotedAttachmentView addSubview:contentImageView]; - quotedAttachmentView.backgroundColor = self.highlightColor; - [contentImageView autoCenterInSuperview]; - [contentImageView - autoSetDimensionsToSize:CGSizeMake(self.quotedAttachmentSize * 0.5f, self.quotedAttachmentSize * 0.5f)]; - - UITapGestureRecognizer *tapGesture = - [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapFailedThumbnailDownload:)]; - [quotedAttachmentView addGestureRecognizer:tapGesture]; - quotedAttachmentView.userInteractionEnabled = YES; - } else { - UIImage *contentIcon = [UIImage imageNamed:@"generic-attachment"]; - UIImageView *contentImageView = [self imageViewForImage:contentIcon]; - contentImageView.contentMode = UIViewContentModeScaleAspectFit; - - UIView *wrapper = [UIView containerView]; - [wrapper addSubview:contentImageView]; - [contentImageView autoCenterInSuperview]; - [contentImageView autoSetDimension:ALDimensionWidth toSize:self.quotedAttachmentSize * 0.5f]; - quotedAttachmentView = wrapper; - } - - [quotedAttachmentView autoPinToSquareAspectRatio]; - [quotedAttachmentView setContentHuggingHigh]; - [quotedAttachmentView setCompressionResistanceHigh]; - [hStackView addArrangedSubview:quotedAttachmentView]; - } else { - // If there's no attachment, add an empty view so that - // the stack view's spacing serves as a margin between - // the text views and the trailing edge. - UIView *emptyView = [UIView containerView]; - [hStackView addArrangedSubview:emptyView]; - [emptyView setContentHuggingHigh]; - [emptyView autoSetDimension:ALDimensionWidth toSize:0.f]; - } - - UIView *contentView = hStackView; - [contentView setContentHuggingHorizontalLow]; - [contentView setCompressionResistanceHorizontalLow]; - - if (self.quotedMessage.isRemotelySourced) { - UIStackView *quoteSourceWrapper = [[UIStackView alloc] initWithArrangedSubviews:@[ - contentView, - [self buildRemoteContentSourceView], - ]]; - quoteSourceWrapper.axis = UILayoutConstraintAxisVertical; - contentView = quoteSourceWrapper; - [contentView setContentHuggingHorizontalLow]; - [contentView setCompressionResistanceHorizontalLow]; - } - - if (self.isForPreview) { - UIButton *cancelButton = [UIButton buttonWithType:UIButtonTypeCustom]; - UIColor *tintColor = [LKAppModeUtilities isLightMode] ? UIColor.blackColor : UIColor.whiteColor; - UIImage *cancelIcon = [[UIImage imageNamed:@"X"] asTintedImageWithColor:tintColor]; - [cancelButton setImage:cancelIcon forState:UIControlStateNormal]; - cancelButton.contentMode = UIViewContentModeScaleAspectFit; - [cancelButton addTarget:self action:@selector(didTapCancel) forControlEvents:UIControlEventTouchUpInside]; - [cancelButton autoSetDimension:ALDimensionWidth toSize:14.f]; - [cancelButton autoSetDimension:ALDimensionHeight toSize:14.f]; - - UIStackView *cancelStack = [[UIStackView alloc] initWithArrangedSubviews:@[ cancelButton ]]; - cancelStack.axis = UILayoutConstraintAxisHorizontal; - cancelStack.alignment = UIStackViewAlignmentTop; - cancelStack.layoutMarginsRelativeArrangement = YES; - CGFloat hMarginLeading = 8; - CGFloat hMarginTrailing = 8; - cancelStack.layoutMargins = UIEdgeInsetsMake(8, - CurrentAppContext().isRTL ? hMarginTrailing : hMarginLeading, - 0, - CurrentAppContext().isRTL ? hMarginLeading : hMarginTrailing); - - UIStackView *cancelWrapper = [[UIStackView alloc] initWithArrangedSubviews:@[ - contentView, - cancelStack, - ]]; - cancelWrapper.axis = UILayoutConstraintAxisHorizontal; - - contentView = cancelWrapper; - [contentView setContentHuggingHorizontalLow]; - [contentView setCompressionResistanceHorizontalLow]; - } - - [innerBubbleView addSubview:contentView]; - [contentView ows_autoPinToSuperviewEdges]; -} - -- (UIView *)buildRemoteContentSourceView -{ - UIImage *glyphImage = - [[UIImage imageNamed:@"ic_broken_link"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - OWSAssertDebug(glyphImage); - OWSAssertDebug(CGSizeEqualToSize( - CGSizeMake(kRemotelySourcedContentGlyphLength, kRemotelySourcedContentGlyphLength), glyphImage.size)); - UIImageView *glyphView = [[UIImageView alloc] initWithImage:glyphImage]; - glyphView.tintColor = LKColors.text; - [glyphView - autoSetDimensionsToSize:CGSizeMake(kRemotelySourcedContentGlyphLength, kRemotelySourcedContentGlyphLength)]; - - UILabel *label = [self configureQuoteContentSourceLabel]; - UIStackView *sourceRow = [[UIStackView alloc] initWithArrangedSubviews:@[ glyphView, label ]]; - sourceRow.axis = UILayoutConstraintAxisHorizontal; - sourceRow.alignment = UIStackViewAlignmentCenter; - // TODO verify spacing w/ design - sourceRow.spacing = kRemotelySourcedContentRowSpacing; - sourceRow.layoutMarginsRelativeArrangement = YES; - - const CGFloat leftMargin = 4; - sourceRow.layoutMargins = UIEdgeInsetsMake(kRemotelySourcedContentRowMargin, - leftMargin, - kRemotelySourcedContentRowMargin, - kRemotelySourcedContentRowMargin); - - UIColor *backgroundColor = LKAppModeUtilities.isLightMode ? [UIColor.whiteColor colorWithAlphaComponent:LKValues.unimportantElementOpacity] : [LKColors.text colorWithAlphaComponent:LKValues.unimportantElementOpacity]; - [sourceRow addBackgroundViewWithBackgroundColor:backgroundColor]; - - return sourceRow; -} - -- (void)didTapFailedThumbnailDownload:(UITapGestureRecognizer *)gestureRecognizer -{ - OWSLogDebug(@"in didTapFailedThumbnailDownload"); - - if (!self.quotedMessage.thumbnailDownloadFailed) { - OWSFailDebug(@"thumbnailDownloadFailed was unexpectedly false"); - return; - } - - if (!self.quotedMessage.thumbnailAttachmentPointer) { - OWSFailDebug(@"thumbnailAttachmentPointer was unexpectedly nil"); - return; - } - - [self.delegate didTapQuotedReply:self.quotedMessage - failedThumbnailDownloadAttachmentPointer:self.quotedMessage.thumbnailAttachmentPointer]; -} - -- (nullable UIImage *)tryToLoadThumbnailImage -{ - if (!self.hasQuotedAttachmentThumbnailImage) { - return nil; - } - - // TODO: Possibly ignore data that is too large. - UIImage *_Nullable image = self.quotedMessage.thumbnailImage; - // TODO: Possibly ignore images that are too large. - return image; -} - -- (UIImageView *)imageViewForImage:(UIImage *)image -{ - OWSAssertDebug(image); - - UIImageView *imageView = [UIImageView new]; - imageView.image = image; - // We need to specify a contentMode since the size of the image - // might not match the aspect ratio of the view. - imageView.contentMode = UIViewContentModeScaleAspectFill; - // Use trilinear filters for better scaling quality at - // some performance cost. - imageView.layer.minificationFilter = kCAFilterTrilinear; - imageView.layer.magnificationFilter = kCAFilterTrilinear; - return imageView; -} - -- (UILabel *)configureQuotedTextLabel -{ - OWSAssertDebug(self.quotedTextLabel); - - UIColor *textColor = self.quotedTextColor; - SUPPRESS_DEADSTORE_WARNING(textColor); - UIFont *font = self.quotedTextFont; - SUPPRESS_DEADSTORE_WARNING(font); - NSString *text = @""; - - NSString *_Nullable fileTypeForSnippet = [self fileTypeForSnippet]; - NSString *_Nullable sourceFilename = [self.quotedMessage.sourceFilename filterStringForDisplay]; - - if (self.displayableQuotedText.displayText.length > 0) { - text = self.displayableQuotedText.displayText; - textColor = self.quotedTextColor; - font = self.quotedTextFont; - } else if (fileTypeForSnippet) { - text = fileTypeForSnippet; - textColor = self.fileTypeTextColor; - font = self.fileTypeFont; - } else if (sourceFilename) { - text = sourceFilename; - textColor = self.filenameTextColor; - font = self.filenameFont; - } else { - text = NSLocalizedString( - @"QUOTED_REPLY_TYPE_ATTACHMENT", @"Indicates this message is a quoted reply to an attachment of unknown type."); - textColor = self.fileTypeTextColor; - font = self.fileTypeFont; - } - - self.quotedTextLabel.numberOfLines = self.isForPreview ? 1 : 2; - self.quotedTextLabel.lineBreakMode = NSLineBreakByTruncatingTail; - self.quotedTextLabel.text = text; - self.quotedTextLabel.textColor = textColor; - self.quotedTextLabel.font = font; - - return self.quotedTextLabel; -} - -- (UILabel *)configureQuoteContentSourceLabel -{ - OWSAssertDebug(self.quoteContentSourceLabel); - - self.quoteContentSourceLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize]; - self.quoteContentSourceLabel.textColor = LKColors.text; - self.quoteContentSourceLabel.text = NSLocalizedString(@"QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE", - @"Footer label that appears below quoted messages when the quoted content was not derived locally. When the " - @"local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead " - @"show the content specified by the sender."); - - return self.quoteContentSourceLabel; -} - -- (nullable NSString *)fileTypeForSnippet -{ - // TODO: Are we going to use the filename? For all mimetypes? - NSString *_Nullable contentType = self.quotedMessage.contentType; - if (contentType.length < 1) { - return nil; - } - - if ([MIMETypeUtil isAudio:contentType]) { - return NSLocalizedString( - @"QUOTED_REPLY_TYPE_AUDIO", @"Indicates this message is a quoted reply to an audio file."); - } else if ([MIMETypeUtil isVideo:contentType]) { - return NSLocalizedString( - @"QUOTED_REPLY_TYPE_VIDEO", @"Indicates this message is a quoted reply to a video file."); - } else if ([MIMETypeUtil isImage:contentType]) { - return NSLocalizedString( - @"QUOTED_REPLY_TYPE_IMAGE", @"Indicates this message is a quoted reply to an image file."); - } else if ([MIMETypeUtil isAnimated:contentType]) { - return NSLocalizedString( - @"QUOTED_REPLY_TYPE_GIF", @"Indicates this message is a quoted reply to animated GIF file."); - } - return nil; -} - -- (BOOL)isAudioAttachment -{ - // TODO: Are we going to use the filename? For all mimetypes? - NSString *_Nullable contentType = self.quotedMessage.contentType; - if (contentType.length < 1) { - return NO; - } - - return [MIMETypeUtil isAudio:contentType]; -} - -- (BOOL)isVideoAttachment -{ - // TODO: Are we going to use the filename? For all mimetypes? - NSString *_Nullable contentType = self.quotedMessage.contentType; - if (contentType.length < 1) { - return NO; - } - - return [MIMETypeUtil isVideo:contentType]; -} - -- (UILabel *)configureQuotedAuthorLabel -{ - OWSAssertDebug(self.quotedAuthorLabel); - - NSString *_Nullable localNumber = [TSAccountManager localNumber]; - NSString *quotedAuthorText; - if ([localNumber isEqualToString:self.quotedMessage.authorId]) { - quotedAuthorText = NSLocalizedString(@"You", @""); - } else { - __block NSString *quotedAuthor = [SSKEnvironment.shared.profileManager profileNameForRecipientWithID:self.quotedMessage.authorId] ?: self.quotedMessage.authorId; - - if (quotedAuthor == self.quotedMessage.authorId) { - SNOpenGroup *openGroup = [LKStorage.shared getOpenGroupForThreadID:self.quotedMessage.threadId]; - [OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - if (openGroup != nil) { - quotedAuthor = [LKUserDisplayNameUtilities getPublicChatDisplayNameFor:self.quotedMessage.authorId in:openGroup.channel on:openGroup.server using:transaction]; - } - }]; - } - - quotedAuthorText = quotedAuthor; - } - - self.quotedAuthorLabel.text = quotedAuthorText; - self.quotedAuthorLabel.font = self.quotedAuthorFont; - // TODO: - self.quotedAuthorLabel.textColor = [self quotedAuthorColor]; - self.quotedAuthorLabel.lineBreakMode = NSLineBreakByTruncatingTail; - self.quotedAuthorLabel.numberOfLines = 1; - - return self.quotedAuthorLabel; -} - -#pragma mark - Measurement - -- (CGFloat)textVMargin -{ - return 7.f; -} - -- (CGSize)sizeForMaxWidth:(CGFloat)maxWidth -{ - CGSize result = CGSizeZero; - - result.width += self.bubbleHMargin * 2 + self.stripeThickness + self.hSpacing * 2; - - CGFloat thumbnailHeight = 0.f; - if (self.hasQuotedAttachment) { - result.width += self.quotedAttachmentSize; - - thumbnailHeight += self.quotedAttachmentSize; - } - - // Quoted Author - CGFloat textWidth = 0.f; - CGFloat maxTextWidth = maxWidth - result.width; - CGFloat textHeight = self.textVMargin * 2 + self.quotedAuthorHeight + self.vSpacing; - { - UILabel *quotedAuthorLabel = [self configureQuotedAuthorLabel]; - - CGSize quotedAuthorSize = CGSizeCeil([quotedAuthorLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); - textWidth = quotedAuthorSize.width; - } - - { - UILabel *quotedTextLabel = [self configureQuotedTextLabel]; - - CGSize textSize = CGSizeCeil([quotedTextLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); - textWidth = MAX(textWidth, textSize.width); - textHeight += textSize.height; - } - - if (self.quotedMessage.isRemotelySourced) { - UILabel *quoteContentSourceLabel = [self configureQuoteContentSourceLabel]; - CGSize textSize = CGSizeCeil([quoteContentSourceLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); - CGFloat sourceStackViewHeight = MAX(kRemotelySourcedContentGlyphLength, textSize.height); - - textWidth - = MAX(textWidth, textSize.width + kRemotelySourcedContentGlyphLength + kRemotelySourcedContentRowSpacing); - result.height += kRemotelySourcedContentRowMargin * 2 + sourceStackViewHeight; - } - - textWidth = MIN(textWidth, maxTextWidth); - result.width += textWidth; - result.height += MAX(textHeight, thumbnailHeight); - - return CGSizeCeil(result); -} - -- (UIFont *)quotedAuthorFont -{ - return [UIFont boldSystemFontOfSize:LKValues.smallFontSize]; -} - -- (UIColor *)quotedAuthorColor -{ - return [self.conversationStyle quotedReplyAuthorColor]; -} - -- (UIColor *)quotedTextColor -{ - return [self.conversationStyle quotedReplyTextColor]; -} - -- (UIFont *)quotedTextFont -{ - return [UIFont systemFontOfSize:LKValues.smallFontSize]; -} - -- (UIColor *)fileTypeTextColor -{ - return [self.conversationStyle quotedReplyAttachmentColor]; -} - -- (UIFont *)fileTypeFont -{ - return self.quotedTextFont; -} - -- (UIColor *)filenameTextColor -{ - return [self.conversationStyle quotedReplyAttachmentColor]; -} - -- (UIFont *)filenameFont -{ - return self.quotedTextFont; -} - -- (CGFloat)quotedAuthorHeight -{ - return (CGFloat)ceil([self quotedAuthorFont].lineHeight * 1.f); -} - -- (CGFloat)quotedAttachmentSize -{ - return 54.f; -} - -#pragma mark - - -- (CGSize)sizeThatFits:(CGSize)size -{ - return [self sizeForMaxWidth:CGFLOAT_MAX]; -} - -- (void)didTapCancel -{ - [self.delegate didCancelQuotedReply]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSSystemMessageCell.h b/Session/Conversations/Views & Cells/OWSSystemMessageCell.h deleted file mode 100644 index d56bc4a5e..000000000 --- a/Session/Conversations/Views & Cells/OWSSystemMessageCell.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "ConversationViewCell.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSSystemMessageCell : ConversationViewCell - -+ (NSString *)cellReuseIdentifier; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/OWSSystemMessageCell.m b/Session/Conversations/Views & Cells/OWSSystemMessageCell.m deleted file mode 100644 index 6c4ce6954..000000000 --- a/Session/Conversations/Views & Cells/OWSSystemMessageCell.m +++ /dev/null @@ -1,510 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSSystemMessageCell.h" -#import "ConversationViewItem.h" -#import "OWSMessageHeaderView.h" -#import "Session-Swift.h" -#import "UIColor+OWS.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef void (^SystemMessageActionBlock)(void); - -@interface SystemMessageAction : NSObject - -@property (nonatomic) NSString *title; -@property (nonatomic) SystemMessageActionBlock block; - -@end - -#pragma mark - - -@implementation SystemMessageAction - -+ (SystemMessageAction *)actionWithTitle:(NSString *)title block:(SystemMessageActionBlock)block -{ - SystemMessageAction *action = [SystemMessageAction new]; - action.title = title; - action.block = block; - return action; -} - -@end - -#pragma mark - - -@interface OWSSystemMessageCell () - -@property (nonatomic) UIImageView *iconView; -@property (nonatomic) UILabel *titleLabel; -@property (nonatomic) UIButton *button; -@property (nonatomic) UIStackView *vStackView; -@property (nonatomic) UIView *cellBackgroundView; -@property (nonatomic) OWSMessageHeaderView *headerView; -@property (nonatomic) NSLayoutConstraint *headerViewHeightConstraint; -@property (nonatomic) NSArray *layoutConstraints; -@property (nonatomic, nullable) SystemMessageAction *action; - -@end - -#pragma mark - - -@implementation OWSSystemMessageCell - -// `[UIView init]` invokes `[self initWithFrame:...]`. -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - [self commonInit]; - } - - return self; -} - -- (void)commonInit -{ - OWSAssertDebug(!self.iconView); - - self.layoutMargins = UIEdgeInsetsZero; - self.contentView.layoutMargins = UIEdgeInsetsZero; - self.layoutConstraints = @[]; - - self.headerView = [OWSMessageHeaderView new]; - self.headerViewHeightConstraint = [self.headerView autoSetDimension:ALDimensionHeight toSize:0]; - - self.iconView = [UIImageView new]; - [self.iconView autoSetDimension:ALDimensionWidth toSize:self.iconSize]; - [self.iconView autoSetDimension:ALDimensionHeight toSize:self.iconSize]; - [self.iconView setContentHuggingHigh]; - - self.titleLabel = [UILabel new]; - self.titleLabel.numberOfLines = 0; - self.titleLabel.lineBreakMode = NSLineBreakByWordWrapping; - self.titleLabel.textAlignment = NSTextAlignmentCenter; - - UIStackView *contentStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ - self.iconView, - self.titleLabel, - ]]; - contentStackView.axis = UILayoutConstraintAxisVertical; - contentStackView.spacing = self.iconVSpacing; - contentStackView.alignment = UIStackViewAlignmentCenter; - - self.button = [UIButton new]; - [self.button setTitleColor:[UIColor ows_darkSkyBlueColor] forState:UIControlStateNormal]; - self.button.titleLabel.textAlignment = NSTextAlignmentCenter; - self.button.layer.cornerRadius = LKValues.modalButtonCornerRadius; - self.button.backgroundColor = LKColors.buttonBackground; - self.button.titleLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize]; - [self.button addTarget:self action:@selector(buttonWasPressed:) forControlEvents:UIControlEventTouchUpInside]; - [self.button autoSetDimension:ALDimensionHeight toSize:self.buttonHeight]; - - self.vStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ - contentStackView, - self.button, - ]]; - self.vStackView.axis = UILayoutConstraintAxisVertical; - self.vStackView.spacing = self.buttonVSpacing; - self.vStackView.alignment = UIStackViewAlignmentCenter; - self.vStackView.layoutMarginsRelativeArrangement = YES; - - self.cellBackgroundView = [UIView new]; - self.cellBackgroundView.layer.cornerRadius = 5.f; - [self.contentView addSubview:self.cellBackgroundView]; - - UIStackView *cellStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ self.headerView, self.vStackView ]]; - cellStackView.axis = UILayoutConstraintAxisVertical; - [self.contentView addSubview:cellStackView]; - [cellStackView autoPinEdgesToSuperviewEdges]; - - UILongPressGestureRecognizer *longPress = - [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGesture:)]; - [self addGestureRecognizer:longPress]; -} - -- (CGFloat)buttonVSpacing -{ - return 12.f; -} - -- (CGFloat)iconVSpacing -{ - return LKValues.smallSpacing; -} - -- (CGFloat)buttonHeight -{ - return LKValues.mediumButtonHeight; -} - -- (CGFloat)buttonHPadding -{ - return 20.f; -} - -- (void)configureFonts -{ - // Update cell to reflect changes in dynamic text. - self.titleLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize]; -} - -+ (NSString *)cellReuseIdentifier -{ - return NSStringFromClass([self class]); -} - -- (void)loadForDisplay -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.viewItem); - - self.cellBackgroundView.backgroundColor = UIColor.clearColor; - - [self.button setBackgroundColor:LKColors.buttonBackground]; - - TSInteraction *interaction = self.viewItem.interaction; - - self.action = [self actionForInteraction:interaction]; - - UIImage *_Nullable icon = [self iconForInteraction:interaction]; - if (icon) { - self.iconView.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - self.iconView.hidden = NO; - self.iconView.tintColor = LKColors.text; - } else { - self.iconView.hidden = YES; - } - - self.titleLabel.textColor = [self textColor]; - [self applyTitleForInteraction:interaction label:self.titleLabel]; - CGSize titleSize = [self titleSize]; - - if (self.action) { - [self.button setTitle:self.action.title forState:UIControlStateNormal]; - UIFont *buttonFont = [UIFont systemFontOfSize:LKValues.smallFontSize]; - self.button.titleLabel.font = buttonFont; - [self.button setTitleColor:LKColors.text forState:UIControlStateNormal]; - self.button.hidden = NO; - } else { - self.button.hidden = YES; - } - CGSize buttonSize = [self.button sizeThatFits:CGSizeZero]; - - [NSLayoutConstraint deactivateConstraints:self.layoutConstraints]; - - if (self.viewItem.hasCellHeader) { - self.headerView.hidden = NO; - - CGFloat headerHeight = - [self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle] - .height; - - [self.headerView loadForDisplayWithViewItem:self.viewItem conversationStyle:self.conversationStyle]; - self.headerViewHeightConstraint.constant = headerHeight; - } else { - self.headerView.hidden = YES; - } - - self.vStackView.layoutMargins = UIEdgeInsetsMake(self.topVMargin, - self.conversationStyle.fullWidthGutterLeading, - self.bottomVMargin, - self.conversationStyle.fullWidthGutterLeading); - - self.layoutConstraints = @[ - [self.titleLabel autoSetDimension:ALDimensionWidth toSize:titleSize.width], - [self.button autoSetDimension:ALDimensionWidth toSize:buttonSize.width + self.buttonHPadding * 2.f], - - [self.cellBackgroundView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:self.vStackView], - [self.cellBackgroundView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.vStackView], - // Text in vStackView might flow right up to the edges, so only use half the gutter. - [self.cellBackgroundView autoPinEdgeToSuperviewEdge:ALEdgeLeading - withInset:self.conversationStyle.fullWidthGutterLeading * 0.5f], - [self.cellBackgroundView autoPinEdgeToSuperviewEdge:ALEdgeTrailing - withInset:self.conversationStyle.fullWidthGutterTrailing * 0.5f], - ]; -} - -- (UIColor *)textColor -{ - return LKColors.text; -} - -- (UIColor *)iconColorForInteraction:(TSInteraction *)interaction -{ - return LKColors.text; -} - -- (nullable UIImage *)iconForInteraction:(TSInteraction *)interaction -{ - UIImage *result = nil; - - if ([interaction isKindOfClass:[TSErrorMessage class]]) { - switch (((TSErrorMessage *)interaction).errorType) { - case TSErrorMessageNonBlockingIdentityChange: - case TSErrorMessageWrongTrustedIdentityKey: - result = [UIImage imageNamed:@"system_message_security"]; - break; - case TSErrorMessageInvalidKeyException: - case TSErrorMessageMissingKeyId: - case TSErrorMessageNoSession: - case TSErrorMessageInvalidMessage: - case TSErrorMessageDuplicateMessage: - case TSErrorMessageInvalidVersion: - case TSErrorMessageUnknownContactBlockOffer: - case TSErrorMessageGroupCreationFailed: - return nil; - } - } else if ([interaction isKindOfClass:[TSInfoMessage class]]) { - switch (((TSInfoMessage *)interaction).messageType) { - case TSInfoMessageUserNotRegistered: - case TSInfoMessageTypeSessionDidEnd: - case TSInfoMessageTypeUnsupportedMessage: - case TSInfoMessageAddToContactsOffer: - case TSInfoMessageAddUserToProfileWhitelistOffer: - case TSInfoMessageAddGroupToProfileWhitelistOffer: - case TSInfoMessageTypeGroupUpdate: - case TSInfoMessageTypeGroupQuit: - return nil; - case TSInfoMessageTypeDisappearingMessagesUpdate: { - BOOL areDisappearingMessagesEnabled = YES; - if ([interaction isKindOfClass:[OWSDisappearingConfigurationUpdateInfoMessage class]]) { - areDisappearingMessagesEnabled - = ((OWSDisappearingConfigurationUpdateInfoMessage *)interaction).configurationIsEnabled; - } else { - OWSFailDebug(@"unexpected interaction type: %@", interaction.class); - } - result = (areDisappearingMessagesEnabled - ? [UIImage imageNamed:@"system_message_disappearing_messages"] - : [UIImage imageNamed:@"system_message_disappearing_messages_disabled"]); - break; - } - } - } else { - OWSFailDebug(@"Unknown interaction type: %@", [interaction class]); - return nil; - } - OWSAssertDebug(result); - return result; -} - -- (void)applyTitleForInteraction:(TSInteraction *)interaction - label:(UILabel *)label -{ - OWSAssertDebug(interaction); - OWSAssertDebug(label); - OWSAssertDebug(self.viewItem.systemMessageText.length > 0); - - [self configureFonts]; - - label.text = self.viewItem.systemMessageText; -} - -- (CGFloat)topVMargin -{ - return LKValues.smallSpacing; -} - -- (CGFloat)bottomVMargin -{ - return LKValues.smallSpacing; -} - -- (CGFloat)hSpacing -{ - return LKValues.mediumSpacing; -} - -- (CGFloat)iconSize -{ - return 20.f; -} - -- (CGSize)titleSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.viewItem); - - CGFloat maxTitleWidth = (CGFloat)floor(self.conversationStyle.fullWidthContentWidth); - return [self.titleLabel sizeThatFits:CGSizeMake(maxTitleWidth, CGFLOAT_MAX)]; -} - -- (CGSize)cellSize -{ - OWSAssertDebug(self.conversationStyle); - OWSAssertDebug(self.viewItem); - - TSInteraction *interaction = self.viewItem.interaction; - - CGSize result = CGSizeMake(self.conversationStyle.viewWidth, 0); - - if (self.viewItem.hasCellHeader) { - result.height += - [self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle] - .height; - } - - UIImage *_Nullable icon = [self iconForInteraction:interaction]; - if (icon) { - result.height += self.iconSize + self.iconVSpacing; - } - - [self applyTitleForInteraction:interaction label:self.titleLabel]; - CGSize titleSize = [self titleSize]; - result.height += titleSize.height; - - SystemMessageAction *_Nullable action = [self actionForInteraction:interaction]; - if (action) { - result.height += self.buttonHeight + self.buttonVSpacing; - } - - result.height += self.topVMargin + self.bottomVMargin; - - return result; -} - -#pragma mark - Actions - -- (nullable SystemMessageAction *)actionForInteraction:(TSInteraction *)interaction -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(interaction); - - if ([interaction isKindOfClass:[TSErrorMessage class]]) { - return [self actionForErrorMessage:(TSErrorMessage *)interaction]; - } else if ([interaction isKindOfClass:[TSInfoMessage class]]) { - return [self actionForInfoMessage:(TSInfoMessage *)interaction]; - } else { - OWSFailDebug(@"Tap for system messages of unknown type: %@", [interaction class]); - return nil; - } -} - -- (nullable SystemMessageAction *)actionForErrorMessage:(TSErrorMessage *)message -{ - OWSAssertDebug(message); - - __weak OWSSystemMessageCell *weakSelf = self; - switch (message.errorType) { - case TSErrorMessageInvalidKeyException: - return nil; - case TSErrorMessageMissingKeyId: - case TSErrorMessageNoSession: - case TSErrorMessageInvalidMessage: - return nil; - case TSErrorMessageDuplicateMessage: - case TSErrorMessageInvalidVersion: - return nil; - case TSErrorMessageUnknownContactBlockOffer: - OWSFailDebug(@"TSErrorMessageUnknownContactBlockOffer"); - return nil; - case TSErrorMessageGroupCreationFailed: - return [SystemMessageAction actionWithTitle:CommonStrings.retryButton - block:^{ - [weakSelf.delegate resendGroupUpdateForErrorMessage:message]; - }]; - } - - OWSLogWarn(@"Unhandled tap for error message:%@", message); - return nil; -} - -- (nullable SystemMessageAction *)actionForInfoMessage:(TSInfoMessage *)message -{ - OWSAssertDebug(message); - - __weak OWSSystemMessageCell *weakSelf = self; - switch (message.messageType) { - case TSInfoMessageUserNotRegistered: - case TSInfoMessageTypeSessionDidEnd: - return nil; - case TSInfoMessageTypeUnsupportedMessage: - // Unused. - return nil; - case TSInfoMessageAddToContactsOffer: - // Unused. - OWSFailDebug(@"TSInfoMessageAddToContactsOffer"); - return nil; - case TSInfoMessageAddUserToProfileWhitelistOffer: - // Unused. - OWSFailDebug(@"TSInfoMessageAddUserToProfileWhitelistOffer"); - return nil; - case TSInfoMessageAddGroupToProfileWhitelistOffer: - // Unused. - OWSFailDebug(@"TSInfoMessageAddGroupToProfileWhitelistOffer"); - return nil; - case TSInfoMessageTypeGroupUpdate: - return nil; - case TSInfoMessageTypeGroupQuit: - return nil; - case TSInfoMessageTypeDisappearingMessagesUpdate: - return [SystemMessageAction actionWithTitle:NSLocalizedString(@"CONVERSATION_SETTINGS_TAP_TO_CHANGE", - @"Label for button that opens conversation settings.") - block:^{ - [weakSelf.delegate showConversationSettings]; - }]; - } - - OWSLogInfo(@"Unhandled tap for info message: %@", message); - return nil; -} - -#pragma mark - Events - -- (void)handleLongPressGesture:(UILongPressGestureRecognizer *)longPress -{ - OWSAssertDebug(self.delegate); - - if ([self isGestureInCellHeader:longPress]) { - return; - } - - TSInteraction *interaction = self.viewItem.interaction; - OWSAssertDebug(interaction); - - if (longPress.state == UIGestureRecognizerStateBegan) { - [self.delegate conversationCell:self didLongpressSystemMessageViewItem:self.viewItem]; - } -} - -- (BOOL)isGestureInCellHeader:(UIGestureRecognizer *)sender -{ - OWSAssertDebug(self.viewItem); - - if (!self.viewItem.hasCellHeader) { - return NO; - } - - CGPoint location = [sender locationInView:self]; - CGPoint headerBottom = [self convertPoint:CGPointMake(0, self.headerView.height) fromView:self.headerView]; - return location.y <= headerBottom.y; -} - -- (void)buttonWasPressed:(id)sender -{ - if (!self.action.block) { - OWSFailDebug(@"Missing action"); - } else { - self.action.block(); - } -} - -#pragma mark - Reuse - -- (void)prepareForReuse -{ - [super prepareForReuse]; - - self.action = nil; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Conversations/Views & Cells/QuotedReplyPreview.swift b/Session/Conversations/Views & Cells/QuotedReplyPreview.swift deleted file mode 100644 index 06e159c1d..000000000 --- a/Session/Conversations/Views & Cells/QuotedReplyPreview.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -protocol QuotedReplyPreviewDelegate: class { - func quotedReplyPreviewDidPressCancel(_ preview: QuotedReplyPreview) -} - -@objc -class QuotedReplyPreview: UIView, OWSQuotedMessageViewDelegate { - @objc - public weak var delegate: QuotedReplyPreviewDelegate? - - private let quotedReply: OWSQuotedReplyModel - private let conversationStyle: ConversationStyle - private var quotedMessageView: OWSQuotedMessageView? - private var heightConstraint: NSLayoutConstraint! - - @available(*, unavailable, message:"use other constructor instead.") - required init(coder aDecoder: NSCoder) { - notImplemented() - } - - @available(*, unavailable, message:"use other constructor instead.") - override init(frame: CGRect) { - notImplemented() - } - - @objc - init(quotedReply: OWSQuotedReplyModel, conversationStyle: ConversationStyle) { - self.quotedReply = quotedReply - self.conversationStyle = conversationStyle - - super.init(frame: .zero) - - self.heightConstraint = self.autoSetDimension(.height, toSize: 0) - - updateContents() - - NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) - } - - private let draftMarginTop: CGFloat = 6 - - func updateContents() { - subviews.forEach { $0.removeFromSuperview() } - - let hMargin: CGFloat = 6 - self.layoutMargins = UIEdgeInsets(top: draftMarginTop, - left: hMargin, - bottom: 0, - right: hMargin) - - // We instantiate quotedMessageView late to ensure that it is updated - // every time contentSizeCategoryDidChange (i.e. when dynamic type - // sizes changes). - let quotedMessageView = OWSQuotedMessageView(forPreview: quotedReply, conversationStyle: conversationStyle) - quotedMessageView.delegate = self - self.quotedMessageView = quotedMessageView - quotedMessageView.setContentHuggingHorizontalLow() - quotedMessageView.setCompressionResistanceHorizontalLow() - quotedMessageView.backgroundColor = .clear - self.addSubview(quotedMessageView) - quotedMessageView.ows_autoPinToSuperviewMargins() - - updateHeight() - } - - // MARK: Sizing - - func updateHeight() { - guard let quotedMessageView = quotedMessageView else { - owsFailDebug("missing quotedMessageView") - return - } - let size = quotedMessageView.size(forMaxWidth: CGFloat.infinity) - self.heightConstraint.constant = size.height + draftMarginTop - } - - @objc func contentSizeCategoryDidChange(_ notification: Notification) { - Logger.debug("") - - updateContents() - } - - // MARK: - OWSQuotedMessageViewDelegate - - @objc public func didTapQuotedReply(_ quotedReply: OWSQuotedReplyModel, failedThumbnailDownloadAttachmentPointer attachmentPointer: TSAttachmentPointer) { - // Do nothing. - } - - @objc public func didCancelQuotedReply() { - self.delegate?.quotedReplyPreviewDidPressCancel(self) - } -} diff --git a/Session/Conversations/Views & Cells/TypingIndicatorCell.swift b/Session/Conversations/Views & Cells/TypingIndicatorCell.swift deleted file mode 100644 index 064a05ec6..000000000 --- a/Session/Conversations/Views & Cells/TypingIndicatorCell.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc(OWSTypingIndicatorCell) -public class TypingIndicatorCell: ConversationViewCell { - - @objc - public static let cellReuseIdentifier = "TypingIndicatorCell" - - @available(*, unavailable, message:"use other constructor instead.") - @objc - public required init(coder aDecoder: NSCoder) { - notImplemented() - } - - private let kAvatarSize: CGFloat = 36 - private let kAvatarHSpacing: CGFloat = 8 - -// private let avatarView = AvatarImageView() - private let bubbleView = OWSBubbleView() - private let typingIndicatorView = TypingIndicatorView() - private var viewConstraints = [NSLayoutConstraint]() - - override init(frame: CGRect) { - super.init(frame: frame) - - commonInit() - } - - private func commonInit() { - self.layoutMargins = .zero - self.contentView.layoutMargins = .zero - - bubbleView.layoutMargins = .zero - - bubbleView.addSubview(typingIndicatorView) - contentView.addSubview(bubbleView) - -// avatarView.autoSetDimension(.width, toSize: kAvatarSize) -// avatarView.autoSetDimension(.height, toSize: kAvatarSize) - } - - @objc - public override func loadForDisplay() { - guard let conversationStyle = self.conversationStyle else { - owsFailDebug("Missing conversationStyle") - return - } - - bubbleView.bubbleColor = conversationStyle.bubbleColor(isIncoming: true) - typingIndicatorView.startAnimation() - - viewConstraints.append(contentsOf: [ - bubbleView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.gutterLeading), - bubbleView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.gutterTrailing, relation: .greaterThanOrEqual), - bubbleView.autoPinTopToSuperviewMargin(withInset: 0), - bubbleView.autoPinBottomToSuperviewMargin(withInset: 0), - - typingIndicatorView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.textInsetHorizontal), - typingIndicatorView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.textInsetHorizontal), - typingIndicatorView.autoPinTopToSuperviewMargin(withInset: conversationStyle.textInsetTop), - typingIndicatorView.autoPinBottomToSuperviewMargin(withInset: conversationStyle.textInsetBottom) - ]) - -// if let avatarView = configureAvatarView() { -// contentView.addSubview(avatarView) -// viewConstraints.append(contentsOf: [ -// bubbleView.autoPinLeading(toTrailingEdgeOf: avatarView, offset: kAvatarHSpacing), -// bubbleView.autoAlignAxis(.horizontal, toSameAxisOf: avatarView) -// ]) -// -// } else { -// avatarView.removeFromSuperview() -// } - } - - private func configureAvatarView() -> UIView? { - guard let viewItem = self.viewItem else { - owsFailDebug("Missing viewItem") - return nil - } - guard let typingIndicators = viewItem.interaction as? TypingIndicatorInteraction else { - owsFailDebug("Missing typingIndicators") - return nil - } - guard shouldShowAvatar() else { - return nil - } - guard let colorName = viewItem.authorConversationColorName else { - owsFailDebug("Missing authorConversationColorName") - return nil - } -// guard let authorAvatarImage = -// OWSContactAvatarBuilder(signalId: typingIndicators.recipientId, -// colorName: ConversationColorName(rawValue: colorName), -// diameter: UInt(kAvatarSize)).build() else { -// owsFailDebug("Could build avatar image") -// return nil -// } -// avatarView.image = authorAvatarImage -// return avatarView - return UIView() - } - - private func shouldShowAvatar() -> Bool { - guard let viewItem = self.viewItem else { - owsFailDebug("Missing viewItem") - return false - } - return viewItem.isGroupThread - } - - @objc - public override func cellSize() -> CGSize { - guard let conversationStyle = self.conversationStyle else { - owsFailDebug("Missing conversationStyle") - return .zero - } - - let insetsSize = CGSize(width: conversationStyle.textInsetHorizontal * 2, - height: conversationStyle.textInsetTop + conversationStyle.textInsetBottom) - let typingIndicatorSize = typingIndicatorView.sizeThatFits(.zero) - let bubbleSize = CGSizeAdd(insetsSize, typingIndicatorSize) - - if shouldShowAvatar() { - return CGSizeCeil(CGSize(width: kAvatarSize + kAvatarHSpacing + bubbleSize.width, - height: max(kAvatarSize, bubbleSize.height))) - } else { - return CGSizeCeil(CGSize(width: bubbleSize.width, - height: max(kAvatarSize, bubbleSize.height))) - } - } - - @objc - public override func prepareForReuse() { - super.prepareForReuse() - - NSLayoutConstraint.deactivate(viewConstraints) - viewConstraints = [NSLayoutConstraint]() - -// avatarView.image = nil -// avatarView.removeFromSuperview() - - typingIndicatorView.stopAnimation() - } -} diff --git a/Session/Conversations/Views & Cells/VoiceMessageView.swift b/Session/Conversations/Views & Cells/VoiceMessageView.swift deleted file mode 100644 index 85f69a7af..000000000 --- a/Session/Conversations/Views & Cells/VoiceMessageView.swift +++ /dev/null @@ -1,214 +0,0 @@ -import Accelerate -import NVActivityIndicatorView - -@objc(LKVoiceMessageView) -final class VoiceMessageView : UIView { - private let voiceMessage: TSAttachment - private let isOutgoing: Bool - private var isLoading = false - private var isForcedAnimation = false - private var volumeSamples: [Float] = [] { didSet { updateShapeLayers() } } - @objc var progress: CGFloat = 0 { didSet { updateShapeLayers() } } - @objc var duration: Int = 0 { didSet { updateDurationLabel() } } - @objc var isPlaying = false { didSet { updateToggleImageView() } } - - // MARK: Components - private lazy var toggleImageView = UIImageView(image: #imageLiteral(resourceName: "Play")) - - private lazy var spinner = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: .black, padding: nil) - - private lazy var durationLabel: UILabel = { - let result = UILabel() - result.textColor = Colors.text - result.font = .systemFont(ofSize: Values.mediumFontSize) - return result - }() - - private lazy var backgroundShapeLayer: CAShapeLayer = { - let result = CAShapeLayer() - result.fillColor = Colors.text.cgColor - return result - }() - - private lazy var foregroundShapeLayer: CAShapeLayer = { - let result = CAShapeLayer() - result.fillColor = (isLightMode && isOutgoing) ? UIColor.white.cgColor : Colors.accent.cgColor - return result - }() - - // MARK: Settings - private let leadingInset: CGFloat = 0 - private let sampleSpacing: CGFloat = 1 - private let targetSampleCount = 48 - private let toggleContainerSize: CGFloat = 32 - private let vMargin: CGFloat = 0 - - @objc public static let contentHeight: CGFloat = 40 - - // MARK: Initialization - @objc(initWithVoiceMessage:isOutgoing:) - init(voiceMessage: TSAttachment, isOutgoing: Bool) { - self.voiceMessage = voiceMessage - self.isOutgoing = isOutgoing - super.init(frame: CGRect.zero) - } - - override init(frame: CGRect) { - preconditionFailure("Use init(voiceMessage:associatedWith:) instead.") - } - - required init?(coder: NSCoder) { - preconditionFailure("Use init(voiceMessage:associatedWith:) instead.") - } - - @objc func initialize() { - setUpViewHierarchy() - if voiceMessage.isDownloaded { - guard let url = (voiceMessage as? TSAttachmentStream)?.originalMediaURL else { - return SNLog("Couldn't get URL for voice message.") - } - if let cachedVolumeSamples = Storage.shared.getVolumeSamples(for: voiceMessage.uniqueId!), cachedVolumeSamples.count == targetSampleCount { - self.hideLoader() - self.volumeSamples = cachedVolumeSamples - } else { - let voiceMessageID = voiceMessage.uniqueId! - AudioUtilities.getVolumeSamples(for: url, targetSampleCount: targetSampleCount).done(on: DispatchQueue.main) { [weak self] volumeSamples in - guard let self = self else { return } - self.hideLoader() - self.isForcedAnimation = true - self.volumeSamples = volumeSamples - Storage.write { transaction in - Storage.shared.setVolumeSamples(for: voiceMessageID, to: volumeSamples, using: transaction) - } - }.catch(on: DispatchQueue.main) { error in - SNLog("Couldn't sample audio file due to error: \(error).") - } - } - } else { - showLoader() - } - } - - private func setUpViewHierarchy() { - set(.width, to: 200) - set(.height, to: VoiceMessageView.contentHeight) - layer.insertSublayer(backgroundShapeLayer, at: 0) - layer.insertSublayer(foregroundShapeLayer, at: 1) - let toggleContainer = UIView() - toggleContainer.clipsToBounds = false - toggleContainer.addSubview(toggleImageView) - toggleImageView.set(.width, to: 12) - toggleImageView.set(.height, to: 12) - toggleImageView.center(in: toggleContainer) - toggleContainer.addSubview(spinner) - spinner.set(.width, to: 24) - spinner.set(.height, to: 24) - spinner.center(in: toggleContainer) - toggleContainer.set(.width, to: toggleContainerSize) - toggleContainer.set(.height, to: toggleContainerSize) - toggleContainer.layer.cornerRadius = toggleContainerSize / 2 - toggleContainer.backgroundColor = UIColor.white - let glowRadius: CGFloat = isLightMode ? 1 : 2 - let glowColor = isLightMode ? UIColor.black.withAlphaComponent(0.4) : UIColor.black - let glowConfiguration = UIView.CircularGlowConfiguration(size: toggleContainerSize, color: glowColor, radius: glowRadius) - toggleContainer.setCircularGlow(with: glowConfiguration) - addSubview(toggleContainer) - toggleContainer.center(.vertical, in: self) - toggleContainer.pin(.leading, to: .leading, of: self, withInset: leadingInset) - addSubview(durationLabel) - durationLabel.center(.vertical, in: self) - durationLabel.pin(.trailing, to: .trailing, of: self) - } - - // MARK: UI & Updating - private func showLoader() { - isLoading = true - toggleImageView.isHidden = true - spinner.startAnimating() - spinner.isHidden = false - Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] timer in - guard let self = self else { return timer.invalidate() } - if self.isLoading { - self.updateFakeVolumeSamples() - } else { - timer.invalidate() - } - } - updateFakeVolumeSamples() - } - - private func updateFakeVolumeSamples() { - let fakeVolumeSamples = (0.. CGFloat(i) / CGFloat(volumeSamples.count) { foregroundPath.append(subPath) } - } - backgroundPath.close() - foregroundPath.close() - if isLoading || isForcedAnimation { - let animation = CABasicAnimation(keyPath: "path") - animation.duration = 0.25 - animation.toValue = backgroundPath - animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) - backgroundShapeLayer.add(animation, forKey: "path") - backgroundShapeLayer.path = backgroundPath.cgPath - } else { - backgroundShapeLayer.path = backgroundPath.cgPath - } - foregroundShapeLayer.path = foregroundPath.cgPath - isForcedAnimation = false - } - - private func updateDurationLabel() { - durationLabel.text = OWSFormat.formatDurationSeconds(duration) - updateShapeLayers() - } - - private func updateToggleImageView() { - toggleImageView.image = isPlaying ? #imageLiteral(resourceName: "Pause") : #imageLiteral(resourceName: "Play") - } - - // MARK: Interaction - @objc(getCurrentTime:) - func getCurrentTime(for panGestureRecognizer: UIPanGestureRecognizer) -> TimeInterval { - guard voiceMessage.isDownloaded else { return 0 } - let locationInSelf = panGestureRecognizer.location(in: self) - let waveformFrameOrigin = CGPoint(x: leadingInset + toggleContainerSize + Values.smallSpacing, y: vMargin) - let waveformFrameSize = CGSize(width: width() - leadingInset - toggleContainerSize - durationLabel.width() - 2 * Values.smallSpacing, - height: height() - 2 * vMargin) - let waveformFrame = CGRect(origin: waveformFrameOrigin, size: waveformFrameSize) - guard waveformFrame.contains(locationInSelf) else { return 0 } - let fraction = (locationInSelf.x - waveformFrame.minX) / (waveformFrame.maxX - waveformFrame.minX) - return Double(fraction) * Double(duration) - } -} diff --git a/Session/Conversations/Views & Cells/VoiceNoteLock.swift b/Session/Conversations/Views & Cells/VoiceNoteLock.swift deleted file mode 100644 index a94030c04..000000000 --- a/Session/Conversations/Views & Cells/VoiceNoteLock.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@objc -public class VoiceMemoLockView: UIView { - - private var offsetConstraint: NSLayoutConstraint! - - private let offsetFromToolbar: CGFloat = 40 - private let backgroundViewInitialHeight: CGFloat = 80 - private var chevronTravel: CGFloat { - return -1 * (backgroundViewInitialHeight - 50) - } - - @objc - public override init(frame: CGRect) { - super.init(frame: frame) - addSubview(backgroundView) - backgroundView.addSubview(lockIconView) - backgroundView.addSubview(chevronView) - - layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: offsetFromToolbar, trailing: 0) - - backgroundView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom) - self.offsetConstraint = backgroundView.autoPinEdge(toSuperviewMargin: .bottom) - // we anchor the top so that the bottom "slides up" to meet it as the user slides the lock - backgroundView.autoPinEdge(.top, to: .bottom, of: self, withOffset: -offsetFromToolbar - backgroundViewInitialHeight) - - backgroundView.layoutMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6) - - lockIconView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom) - chevronView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - - - @objc - public func update(ratioComplete: CGFloat) { - offsetConstraint.constant = CGFloatLerp(0, chevronTravel, ratioComplete) - } - - // MARK: - Subviews - - private lazy var lockIconView: UIImageView = { - let imageTemplate = #imageLiteral(resourceName: "ic_lock_outline").withRenderingMode(.alwaysTemplate) - let imageView = UIImageView(image: imageTemplate) - imageView.tintColor = Colors.destructive - imageView.autoSetDimensions(to: CGSize(width: 24, height: 24)) - return imageView - }() - - private lazy var chevronView: UIView = { - let label = UILabel() - label.text = "\u{2303}" - label.textColor = Colors.destructive - label.textAlignment = .center - return label - }() - - private lazy var backgroundView: UIView = { - let view = UIView() - - let width: CGFloat = 36 - view.autoSetDimension(.width, toSize: width) - view.backgroundColor = Colors.composeViewBackground - view.layer.cornerRadius = width / 2 - view.layer.borderColor = Colors.text.withAlphaComponent(Values.composeViewTextFieldBorderOpacity).cgColor - view.layer.borderWidth = Values.composeViewTextFieldBorderThickness - - return view - }() - -} diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift new file mode 100644 index 000000000..76b2920f3 --- /dev/null +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -0,0 +1,69 @@ + +final class BlockedModal : Modal { + private let publicKey: String + + // MARK: Lifecycle + init(publicKey: String) { + self.publicKey = publicKey + super.init(nibName: nil, bundle: nil) + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(publicKey:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(publicKey:) instead.") + } + + override func populateContentView() { + // Name + let name = OWSProfileManager.shared().profileNameForRecipient(withID: publicKey, avoidingWriteTransaction: true) ?? publicKey + // Title + let titleLabel = UILabel() + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "Unblock \(name)?" + titleLabel.textAlignment = .center + // Message + let messageLabel = UILabel() + messageLabel.textColor = Colors.text + messageLabel.font = .systemFont(ofSize: Values.smallFontSize) + let message = "Are you sure you want to unblock \(name)?" + let attributedMessage = NSMutableAttributedString(string: message) + attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name)) + messageLabel.attributedText = attributedMessage + messageLabel.numberOfLines = 0 + messageLabel.lineBreakMode = .byWordWrapping + messageLabel.textAlignment = .center + // Unblock button + let unblockButton = UIButton() + unblockButton.set(.height, to: Values.mediumButtonHeight) + unblockButton.layer.cornerRadius = Values.modalButtonCornerRadius + unblockButton.backgroundColor = Colors.buttonBackground + unblockButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) + unblockButton.setTitleColor(Colors.text, for: UIControl.State.normal) + unblockButton.setTitle("Unblock", for: UIControl.State.normal) + unblockButton.addTarget(self, action: #selector(unblock), for: UIControl.Event.touchUpInside) + // Button stack view + let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, unblockButton ]) + buttonStackView.axis = .horizontal + buttonStackView.spacing = Values.mediumSpacing + buttonStackView.distribution = .fillEqually + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) + mainStackView.axis = .vertical + mainStackView.spacing = Values.largeSpacing + contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) + mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) + contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) + contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) + } + + // MARK: Interaction + @objc private func unblock() { + OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey) + presentingViewController?.dismiss(animated: true, completion: nil) + } +} diff --git a/Session/Conversations/Views & Modals/BodyTextView.swift b/Session/Conversations/Views & Modals/BodyTextView.swift new file mode 100644 index 000000000..3048db56d --- /dev/null +++ b/Session/Conversations/Views & Modals/BodyTextView.swift @@ -0,0 +1,49 @@ + +// Requirements: +// • Links should show up properly and be tappable. +// • Text should * not * be selectable. +// • The long press interaction that shows the context menu should still work. + +final class BodyTextView : UITextView { + private let snDelegate: BodyTextViewDelegate + + override var selectedTextRange: UITextRange? { + get { return nil } + set { } + } + + init(snDelegate: BodyTextViewDelegate) { + self.snDelegate = snDelegate + super.init(frame: CGRect.zero, textContainer: nil) + setUpGestureRecognizers() + } + + override init(frame: CGRect, textContainer: NSTextContainer?) { + preconditionFailure("Use init(snDelegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(snDelegate:) instead.") + } + + private func setUpGestureRecognizers() { + let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + addGestureRecognizer(longPressGestureRecognizer) + let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) + doubleTapGestureRecognizer.numberOfTapsRequired = 2 + addGestureRecognizer(doubleTapGestureRecognizer) + } + + @objc private func handleLongPress() { + snDelegate.handleLongPress() + } + + @objc private func handleDoubleTap() { + // Do nothing + } +} + +protocol BodyTextViewDelegate { + + func handleLongPress() +} diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift new file mode 100644 index 000000000..82ac85f3d --- /dev/null +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -0,0 +1,109 @@ + +final class ConversationTitleView : UIView { + private let thread: TSThread + + override var intrinsicContentSize: CGSize { + return UIView.layoutFittingExpandedSize + } + + // MARK: UI Components + private lazy var titleLabel: UILabel = { + let result = UILabel() + result.textColor = Colors.text + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.lineBreakMode = .byTruncatingTail + return result + }() + + private lazy var subtitleLabel: UILabel = { + let result = UILabel() + result.textColor = Colors.text + result.font = .systemFont(ofSize: 13) + result.lineBreakMode = .byTruncatingTail + return result + }() + + // MARK: Lifecycle + init(thread: TSThread) { + self.thread = thread + super.init(frame: CGRect.zero) + initialize() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(thread:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(coder:) instead.") + } + + private func initialize() { + let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0) + addSubview(stackView) + stackView.pin(to: self) + NotificationCenter.default.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil) + update() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: Updating + @objc private func update() { + titleLabel.text = getTitle() + let subtitle = getSubtitle() + subtitleLabel.attributedText = subtitle + let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize + titleLabel.font = .boldSystemFont(ofSize: titleFontSize) + } + + // MARK: General + private func getTitle() -> String { + if let thread = thread as? TSGroupThread { + return thread.groupModel.groupName! + } else if thread.isNoteToSelf() { + return "Note to Self" + } else { + let sessionID = thread.contactIdentifier()! + var result = sessionID + Storage.read { transaction in + result = Storage.shared.getContact(with: sessionID)?.displayName ?? "Anonymous" + } + return result + } + } + + private func getSubtitle() -> NSAttributedString? { + if let muteEndDate = thread.mutedUntilDate, thread.isMuted { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ])) + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeStyle = .medium + formatter.dateStyle = .medium + result.append(NSAttributedString(string: "Muted until " + formatter.string(from: muteEndDate))) + return result + } else if let thread = self.thread as? TSGroupThread { + var userCount: Int? + switch thread.groupModel.groupType { + case .closedGroup: userCount = thread.groupModel.groupMemberIds.count + case .openGroup: + if let openGroup = Storage.shared.getOpenGroup(for: self.thread.uniqueId!) { + userCount = Storage.shared.getUserCount(forOpenGroupWithID: openGroup.id) + } + default: break + } + if let userCount = userCount { + return NSAttributedString(string: "\(userCount) members") + } + } + return nil + } +} diff --git a/Session/Conversations/Views & Modals/InfoBanner.swift b/Session/Conversations/Views & Modals/InfoBanner.swift new file mode 100644 index 000000000..8dd586a15 --- /dev/null +++ b/Session/Conversations/Views & Modals/InfoBanner.swift @@ -0,0 +1,33 @@ + +final class InfoBanner : UIView { + private let message: String + private let snBackgroundColor: UIColor + + init(message: String, backgroundColor: UIColor) { + self.message = message + self.snBackgroundColor = backgroundColor + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(message:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(coder:) instead.") + } + + private func setUpViewHierarchy() { + backgroundColor = snBackgroundColor + let label = UILabel() + label.text = message + label.font = .boldSystemFont(ofSize: Values.smallFontSize) + label.textColor = .white + label.numberOfLines = 0 + label.textAlignment = .center + label.lineBreakMode = .byWordWrapping + addSubview(label) + label.pin(to: self, withInset: Values.mediumSpacing) + } +} diff --git a/Session/Conversations/Views & Modals/LinkPreviewModal.swift b/Session/Conversations/Views & Modals/LinkPreviewModal.swift new file mode 100644 index 000000000..7b94e06ba --- /dev/null +++ b/Session/Conversations/Views & Modals/LinkPreviewModal.swift @@ -0,0 +1,66 @@ + +final class LinkPreviewModal : Modal { + private let onLinkPreviewsEnabled: () -> Void + + // MARK: Lifecycle + init(onLinkPreviewsEnabled: @escaping () -> Void) { + self.onLinkPreviewsEnabled = onLinkPreviewsEnabled + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(onLinkPreviewsEnabled:) instead.") + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(onLinkPreviewsEnabled:) instead.") + } + + override func populateContentView() { + // Title + let titleLabel = UILabel() + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "Enable Link Previews?" + titleLabel.textAlignment = .center + // Message + let messageLabel = UILabel() + messageLabel.textColor = Colors.text + messageLabel.font = .systemFont(ofSize: Values.smallFontSize) + let message = "Enabling link previews will show previews for URLs you send and receive. This can be useful, but Session will need to contact linked websites to generate previews. You can always disable link previews in Session's settings." + messageLabel.text = message + messageLabel.numberOfLines = 0 + messageLabel.lineBreakMode = .byWordWrapping + messageLabel.textAlignment = .center + // Enable button + let enableButton = UIButton() + enableButton.set(.height, to: Values.mediumButtonHeight) + enableButton.layer.cornerRadius = Values.modalButtonCornerRadius + enableButton.backgroundColor = Colors.buttonBackground + enableButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) + enableButton.setTitleColor(Colors.text, for: UIControl.State.normal) + enableButton.setTitle("Enable", for: UIControl.State.normal) + enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside) + // Button stack view + let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ]) + buttonStackView.axis = .horizontal + buttonStackView.spacing = Values.mediumSpacing + buttonStackView.distribution = .fillEqually + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) + mainStackView.axis = .vertical + mainStackView.spacing = Values.largeSpacing + contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) + mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) + contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) + contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) + } + + // MARK: Interaction + @objc private func enable() { + SSKPreferences.areLinkPreviewsEnabled = true + presentingViewController?.dismiss(animated: true, completion: nil) + onLinkPreviewsEnabled() + } +} diff --git a/Session/Conversations/Views & Modals/MessagesTableView.swift b/Session/Conversations/Views & Modals/MessagesTableView.swift new file mode 100644 index 000000000..2fd65288e --- /dev/null +++ b/Session/Conversations/Views & Modals/MessagesTableView.swift @@ -0,0 +1,49 @@ + +final class MessagesTableView : UITableView { + var keyboardHeight: CGFloat = 0 + + // Overriding contentInset and adjustedContentInset is to keep them from changing when the + // conversation view controller is dismissed. + + override var contentInset: UIEdgeInsets { + get { UIEdgeInsets(top: 0, leading: 0, bottom: MessagesTableView.baselineContentInset + keyboardHeight, trailing: 0) } + set { } + } + + override var adjustedContentInset: UIEdgeInsets { + get { UIEdgeInsets(top: 0, leading: 0, bottom: MessagesTableView.baselineContentInset + keyboardHeight, trailing: 0) } + set { } + } + + private static let baselineContentInset = Values.mediumSpacing + + override init(frame: CGRect, style: UITableView.Style) { + super.init(frame: frame, style: style) + initialize() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initialize() + } + + private func initialize() { + register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier) + register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier) + register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier) + separatorStyle = .none + backgroundColor = .clear + showsVerticalScrollIndicator = false + contentInsetAdjustmentBehavior = .never + keyboardDismissMode = .interactive + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer == panGestureRecognizer { + let v = panGestureRecognizer.velocity(in: self) + return abs(v.x) < 160 + } else { + return true + } + } +} diff --git a/Session/Conversations/Views & Modals/PermissionMissingModal.swift b/Session/Conversations/Views & Modals/PermissionMissingModal.swift new file mode 100644 index 000000000..045329965 --- /dev/null +++ b/Session/Conversations/Views & Modals/PermissionMissingModal.swift @@ -0,0 +1,75 @@ + +final class PermissionMissingModal : Modal { + private let permission: String + private let onCancel: () -> Void + + // MARK: Lifecycle + init(permission: String, onCancel: @escaping () -> Void) { + self.permission = permission + self.onCancel = onCancel + super.init(nibName: nil, bundle: nil) + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(permission:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(permission:) instead.") + } + + override func populateContentView() { + // Title + let titleLabel = UILabel() + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "Session" + titleLabel.textAlignment = .center + // Message + let messageLabel = UILabel() + messageLabel.textColor = Colors.text + messageLabel.font = .systemFont(ofSize: Values.smallFontSize) + let message = "Session needs \(permission) access to continue. You can enable access in the iOS settings." + let attributedMessage = NSMutableAttributedString(string: message) + attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: permission)) + messageLabel.attributedText = attributedMessage + messageLabel.numberOfLines = 0 + messageLabel.lineBreakMode = .byWordWrapping + messageLabel.textAlignment = .center + // Settings button + let settingsButton = UIButton() + settingsButton.set(.height, to: Values.mediumButtonHeight) + settingsButton.layer.cornerRadius = Values.modalButtonCornerRadius + settingsButton.backgroundColor = Colors.buttonBackground + settingsButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) + settingsButton.setTitleColor(Colors.text, for: UIControl.State.normal) + settingsButton.setTitle("Settings", for: UIControl.State.normal) + settingsButton.addTarget(self, action: #selector(goToSettings), for: UIControl.Event.touchUpInside) + // Button stack view + let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, settingsButton ]) + buttonStackView.axis = .horizontal + buttonStackView.spacing = Values.mediumSpacing + buttonStackView.distribution = .fillEqually + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) + mainStackView.axis = .vertical + mainStackView.spacing = Values.largeSpacing + contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) + mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) + contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) + contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) + } + + // MARK: Interaction + @objc private func goToSettings() { + presentingViewController?.dismiss(animated: true, completion: { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + }) + } + + override func close() { + super.close() + onCancel() + } +} diff --git a/Session/Conversations/Views & Modals/ScrollToBottomButton.swift b/Session/Conversations/Views & Modals/ScrollToBottomButton.swift new file mode 100644 index 000000000..b5aaa9e84 --- /dev/null +++ b/Session/Conversations/Views & Modals/ScrollToBottomButton.swift @@ -0,0 +1,67 @@ + +final class ScrollToBottomButton : UIView { + private let delegate: ScrollToBottomButtonDelegate + + // MARK: Settings + private static let size: CGFloat = 40 + private static let iconSize: CGFloat = 16 + + // MARK: Lifecycle + init(delegate: ScrollToBottomButtonDelegate) { + self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(delegate:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(delegate:) instead.") + } + + private func setUpViewHierarchy() { + // Background & blur + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + addSubview(backgroundView) + backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + addSubview(blurView) + blurView.pin(to: self) + // Size & shape + let size = ScrollToBottomButton.size + set(.width, to: size) + set(.height, to: size) + layer.cornerRadius = size / 2 + layer.masksToBounds = true + // Border + layer.borderWidth = 1 + let borderColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.veryLowOpacity) + layer.borderColor = borderColor.cgColor + // Icon + let tint = isLightMode ? UIColor.black : UIColor.white + let icon = UIImage(named: "ic_chevron_down")!.withTint(tint) + let iconImageView = UIImageView(image: icon) + iconImageView.set(.width, to: ScrollToBottomButton.iconSize) + iconImageView.set(.height, to: ScrollToBottomButton.iconSize) + iconImageView.contentMode = .scaleAspectFit + addSubview(iconImageView) + iconImageView.center(in: self) + // Gesture recognizer + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tapGestureRecognizer) + } + + // MARK: Interaction + @objc private func handleTap() { + delegate.handleScrollToBottomButtonTapped() + } +} + +protocol ScrollToBottomButtonDelegate { + + func handleScrollToBottomButtonTapped() +} diff --git a/Session/Conversations/Views & Modals/URLModal.swift b/Session/Conversations/Views & Modals/URLModal.swift new file mode 100644 index 000000000..373348021 --- /dev/null +++ b/Session/Conversations/Views & Modals/URLModal.swift @@ -0,0 +1,69 @@ + +final class URLModal : Modal { + private let url: URL + + // MARK: Lifecycle + init(url: URL) { + self.url = url + super.init(nibName: nil, bundle: nil) + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(url:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(url:) instead.") + } + + override func populateContentView() { + // Title + let titleLabel = UILabel() + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) + titleLabel.text = "Open URL?" + titleLabel.textAlignment = .center + // Message + let messageLabel = UILabel() + messageLabel.textColor = Colors.text + messageLabel.font = .systemFont(ofSize: Values.smallFontSize) + let message = "Are you sure you want to open \(url.absoluteString)?" + let attributedMessage = NSMutableAttributedString(string: message) + attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: url.absoluteString)) + messageLabel.attributedText = attributedMessage + messageLabel.numberOfLines = 0 + messageLabel.lineBreakMode = .byWordWrapping + messageLabel.textAlignment = .center + // Open button + let openButton = UIButton() + openButton.set(.height, to: Values.mediumButtonHeight) + openButton.layer.cornerRadius = Values.modalButtonCornerRadius + openButton.backgroundColor = Colors.buttonBackground + openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) + openButton.setTitleColor(Colors.text, for: UIControl.State.normal) + openButton.setTitle("Open", for: UIControl.State.normal) + openButton.addTarget(self, action: #selector(openURL), for: UIControl.Event.touchUpInside) + // Button stack view + let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ]) + buttonStackView.axis = .horizontal + buttonStackView.spacing = Values.mediumSpacing + buttonStackView.distribution = .fillEqually + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) + mainStackView.axis = .vertical + mainStackView.spacing = Values.largeSpacing + contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) + mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) + contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) + contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) + } + + // MARK: Interaction + @objc private func openURL() { + let url = self.url + presentingViewController?.dismiss(animated: true, completion: { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + }) + } +} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 59084a571..eca71c0f5 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -3,7 +3,7 @@ // https://github.com/yapstudios/YapDatabase/wiki/YapDatabaseModifiedNotification for // more information on database handling. -final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, UIViewControllerPreviewingDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { +final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { private var threads: YapDatabaseViewMappings! private var threadViewModelCache: [String:ThreadViewModel] = [:] // Thread ID to ThreadViewModel private var tableViewTopConstraint: NSLayoutConstraint! @@ -36,7 +36,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, UIViewC result.backgroundColor = .clear result.separatorStyle = .none result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) - let bottomInset = Values.newConversationButtonBottomOffset + Values.newConversationButtonExpandedSize + Values.largeSpacing + Values.newConversationButtonCollapsedSize + let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) result.showsVerticalScrollIndicator = false return result @@ -124,10 +124,6 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, UIViewC view.addSubview(newConversationButtonSet) newConversationButtonSet.center(.horizontal, in: view) newConversationButtonSet.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset) // Negative due to how the constraint is set up - // Previewing - if traitCollection.forceTouchCapability == .available { - registerForPreviewing(with: self, sourceView: tableView) - } // Notifications let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(handleYapDatabaseModifiedNotification(_:)), name: .YapDatabaseModified, object: OWSPrimaryStorage.shared().dbNotificationObject) @@ -295,21 +291,6 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, UIViewC present(navigationController, animated: true, completion: nil) } - func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { - guard let indexPath = tableView.indexPathForRow(at: location), let thread = self.thread(at: indexPath.row) else { return nil } - previewingContext.sourceRect = tableView.rectForRow(at: indexPath) - let conversationVC = ConversationViewController() - conversationVC.configure(for: thread, action: .none, focusMessageId: nil) - conversationVC.peekSetup() - return conversationVC - } - - func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) { - guard let conversationVC = viewControllerToCommit as? ConversationViewController else { return } - conversationVC.popped() - navigationController?.pushViewController(conversationVC, animated: false) - } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let thread = self.thread(at: indexPath.row) else { return } show(thread, with: ConversationViewAction.none, highlightedMessageID: nil, animated: true) @@ -321,8 +302,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, UIViewC if let presentedVC = self.presentedViewController { presentedVC.dismiss(animated: false, completion: nil) } - let conversationVC = ConversationViewController() - conversationVC.configure(for: thread, action: action, focusMessageId: highlightedMessageID) + let conversationVC = ConversationVC(thread: thread) self.navigationController?.setViewControllers([ self, conversationVC ], animated: true) } } @@ -403,7 +383,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, UIViewC } @objc func joinOpenGroup() { - let joinOpenGroupVC = JoinPublicChatVC() + let joinOpenGroupVC = JoinOpenGroupVC() let navigationController = OWSNavigationController(rootViewController: joinOpenGroupVC) present(navigationController, animated: true, completion: nil) } diff --git a/Session/Home/NewConversationButtonSet.swift b/Session/Home/NewConversationButtonSet.swift index 4f3b31234..b6f03a221 100644 --- a/Session/Home/NewConversationButtonSet.swift +++ b/Session/Home/NewConversationButtonSet.swift @@ -12,6 +12,8 @@ final class NewConversationButtonSet : UIView { private let iconSize = CGFloat(24) private let maxDragDistance = CGFloat(56) private let dragMargin = CGFloat(16) + static let collapsedButtonSize = CGFloat(60) + static let expandedButtonSize = CGFloat(72) // MARK: Components private lazy var mainButton = NewConversationButton(isMainButton: true, icon: #imageLiteral(resourceName: "Plus").scaled(to: CGSize(width: iconSize, height: iconSize))) @@ -39,7 +41,7 @@ final class NewConversationButtonSet : UIView { createNewClosedGroupButton.isAccessibilityElement = true joinOpenGroupButton.accessibilityLabel = "Join open group button" joinOpenGroupButton.isAccessibilityElement = true - let inset = (Values.newConversationButtonExpandedSize - Values.newConversationButtonCollapsedSize) / 2 + let inset = (NewConversationButtonSet.expandedButtonSize - NewConversationButtonSet.collapsedButtonSize) / 2 addSubview(joinOpenGroupButton) horizontalButtonConstraints[joinOpenGroupButton] = joinOpenGroupButton.pin(.left, to: .left, of: self, withInset: inset) verticalButtonConstraints[joinOpenGroupButton] = joinOpenGroupButton.pin(.bottom, to: .bottom, of: self, withInset: -inset) @@ -52,9 +54,9 @@ final class NewConversationButtonSet : UIView { addSubview(mainButton) mainButton.center(.horizontal, in: self) mainButton.pin(.bottom, to: .bottom, of: self, withInset: -inset) - let width = 2 * Values.newConversationButtonExpandedSize + 2 * spacing + Values.newConversationButtonCollapsedSize + let width = 2 * NewConversationButtonSet.expandedButtonSize + 2 * spacing + NewConversationButtonSet.collapsedButtonSize set(.width, to: width) - let height = Values.newConversationButtonExpandedSize + spacing + Values.newConversationButtonCollapsedSize + let height = NewConversationButtonSet.expandedButtonSize + spacing + NewConversationButtonSet.collapsedButtonSize set(.height, to: height) collapse(withAnimation: false) isUserInteractionEnabled = true @@ -75,8 +77,8 @@ final class NewConversationButtonSet : UIView { let buttons = [ joinOpenGroupButton, createNewPrivateChatButton, createNewClosedGroupButton ] UIView.animate(withDuration: 0.25, animations: { buttons.forEach { $0.alpha = 1 } - let inset = (Values.newConversationButtonExpandedSize - Values.newConversationButtonCollapsedSize) / 2 - let size = Values.newConversationButtonCollapsedSize + let inset = (NewConversationButtonSet.expandedButtonSize - NewConversationButtonSet.collapsedButtonSize) / 2 + let size = NewConversationButtonSet.collapsedButtonSize self.joinOpenGroupButton.frame = CGRect(origin: CGPoint(x: inset, y: self.height() - size - inset), size: CGSize(width: size, height: size)) self.createNewPrivateChatButton.frame = CGRect(center: CGPoint(x: self.bounds.center.x, y: inset + size / 2), size: CGSize(width: size, height: size)) self.createNewClosedGroupButton.frame = CGRect(origin: CGPoint(x: self.width() - size - inset, y: self.height() - size - inset), size: CGSize(width: size, height: size)) @@ -91,14 +93,14 @@ final class NewConversationButtonSet : UIView { UIView.animate(withDuration: isAnimated ? 0.25 : 0) { buttons.forEach { button in button.alpha = 0 - let size = Values.newConversationButtonCollapsedSize + let size = NewConversationButtonSet.collapsedButtonSize button.frame = CGRect(center: self.mainButton.center, size: CGSize(width: size, height: size)) } } } private func reset() { - let mainButtonLocationInSelfCoordinates = CGPoint(x: width() / 2, y: height() - Values.newConversationButtonExpandedSize / 2) + let mainButtonLocationInSelfCoordinates = CGPoint(x: width() / 2, y: height() - NewConversationButtonSet.expandedButtonSize / 2) let mainButtonSize = mainButton.frame.size UIView.animate(withDuration: 0.25) { self.mainButton.frame = CGRect(center: mainButtonLocationInSelfCoordinates, size: mainButtonSize) @@ -120,7 +122,7 @@ final class NewConversationButtonSet : UIView { override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first, isUserDragging else { return } let mainButtonSize = mainButton.frame.size - let mainButtonLocationInSelfCoordinates = CGPoint(x: width() / 2, y: height() - Values.newConversationButtonExpandedSize / 2) + let mainButtonLocationInSelfCoordinates = CGPoint(x: width() / 2, y: height() - NewConversationButtonSet.expandedButtonSize / 2) let touchLocationInSelfCoordinates = touch.location(in: self) mainButton.frame = CGRect(center: touchLocationInSelfCoordinates, size: mainButtonSize) mainButton.alpha = 1 - (touchLocationInSelfCoordinates.distance(to: mainButtonLocationInSelfCoordinates) / maxDragDistance) @@ -159,7 +161,7 @@ final class NewConversationButtonSet : UIView { private func expand(_ button: NewConversationButton) { if let horizontalConstraint = horizontalButtonConstraints[button] { horizontalConstraint.constant = 0 } if let verticalConstraint = verticalButtonConstraints[button] { verticalConstraint.constant = 0 } - let size = Values.newConversationButtonExpandedSize + let size = NewConversationButtonSet.expandedButtonSize let frame = CGRect(center: button.center, size: CGSize(width: size, height: size)) button.widthConstraint.constant = size button.heightConstraint.constant = size @@ -167,7 +169,7 @@ final class NewConversationButtonSet : UIView { self.layoutIfNeeded() button.frame = frame button.layer.cornerRadius = size / 2 - let glowColor = Colors.newConversationButtonShadow + let glowColor = Colors.expandedButtonGlowColor let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: true, radius: isLightMode ? 4 : 6) button.setCircularGlow(with: glowConfiguration) button.backgroundColor = Colors.accent @@ -175,7 +177,7 @@ final class NewConversationButtonSet : UIView { } private func collapse(_ button: NewConversationButton) { - let inset = (Values.newConversationButtonExpandedSize - Values.newConversationButtonCollapsedSize) / 2 + let inset = (NewConversationButtonSet.expandedButtonSize - NewConversationButtonSet.collapsedButtonSize) / 2 if joinOpenGroupButton == expandedButton { horizontalButtonConstraints[joinOpenGroupButton]!.constant = inset verticalButtonConstraints[joinOpenGroupButton]!.constant = -inset @@ -185,7 +187,7 @@ final class NewConversationButtonSet : UIView { horizontalButtonConstraints[createNewClosedGroupButton]!.constant = -inset verticalButtonConstraints[createNewClosedGroupButton]!.constant = -inset } - let size = Values.newConversationButtonCollapsedSize + let size = NewConversationButtonSet.collapsedButtonSize let frame = CGRect(center: button.center, size: CGSize(width: size, height: size)) button.widthConstraint.constant = size button.heightConstraint.constant = size @@ -249,9 +251,9 @@ private final class NewConversationButton : UIImageView { private func setUpViewHierarchy(isUpdate: Bool = false) { let newConversationButtonCollapsedBackground = isLightMode ? UIColor(hex: 0xF5F5F5) : UIColor(hex: 0x1F1F1F) backgroundColor = isMainButton ? Colors.accent : newConversationButtonCollapsedBackground - let size = Values.newConversationButtonCollapsedSize + let size = NewConversationButtonSet.collapsedButtonSize layer.cornerRadius = size / 2 - let glowColor = isMainButton ? Colors.newConversationButtonShadow : (isLightMode ? UIColor.black.withAlphaComponent(0.4) : UIColor.black) + let glowColor = isMainButton ? Colors.expandedButtonGlowColor : (isLightMode ? UIColor.black.withAlphaComponent(0.4) : UIColor.black) let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: false, radius: isLightMode ? 4 : 6) setCircularGlow(with: glowConfiguration) layer.masksToBounds = false diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.m b/Session/Media Viewing & Editing/MediaDetailViewController.m index 825660050..1e19acaf4 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.m +++ b/Session/Media Viewing & Editing/MediaDetailViewController.m @@ -4,9 +4,7 @@ #import "MediaDetailViewController.h" #import "AttachmentSharing.h" -#import "ConversationViewController.h" #import "ConversationViewItem.h" -#import "OWSMessageCell.h" #import "Session-Swift.h" #import "TSAttachmentStream.h" #import "TSInteraction.h" diff --git a/Session/Media Viewing & Editing/MediaGalleryViewController.swift b/Session/Media Viewing & Editing/MediaGalleryViewController.swift index 89925344d..af365140b 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewController.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewController.swift @@ -454,7 +454,7 @@ class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDel detailView.backgroundColor = .clear navigationController.view.backgroundColor = .clear - navigationController.presentationView.layer.cornerRadius = kOWSMessageCellCornerRadius_Small + navigationController.presentationView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius fromViewController.present(navigationController, animated: false) { @@ -635,7 +635,7 @@ class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDel if changedItems { self.navigationController.presentationView.alpha = 0 } else { - self.navigationController.presentationView.layer.cornerRadius = kOWSMessageCellCornerRadius_Small + self.navigationController.presentationView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius } }) diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index d4d8206e9..6c1ffd117 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -584,11 +584,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.uiDatabaseConnection.read { transaction in let message = galleryItem.message let thread = message.thread(with: transaction) - let conversationStyle = ConversationStyle(thread: thread) fetchedItem = ConversationInteractionViewItem(interaction: message, isGroupThread: thread.isGroupThread(), - transaction: transaction, - conversationStyle: conversationStyle) + transaction: transaction) } guard let viewItem = fetchedItem else { diff --git a/Session/Meta/AppDelegate.m b/Session/Meta/AppDelegate.m index 4e063823e..92edee00a 100644 --- a/Session/Meta/AppDelegate.m +++ b/Session/Meta/AppDelegate.m @@ -211,7 +211,7 @@ static NSTimeInterval launchStartedAt; [SNConfiguration performMainSetup]; - [LKAppearanceUtilities switchToSessionAppearance]; + [SNAppearance switchToSessionAppearance]; if (CurrentAppContext().isRunningTests) { return YES; @@ -763,11 +763,7 @@ static NSTimeInterval launchStartedAt; - (LKAppMode)getCurrentAppMode { - UIWindow *window = UIApplication.sharedApplication.keyWindow; - if (window == nil) { return LKAppModeLight; } - UIUserInterfaceStyle userInterfaceStyle = window.traitCollection.userInterfaceStyle; - BOOL isLightMode = userInterfaceStyle == UIUserInterfaceStyleLight || userInterfaceStyle == UIUserInterfaceStyleUnspecified; - return isLightMode ? LKAppModeLight : LKAppModeDark; + return [NSUserDefaults.standardUserDefaults integerForKey:@"appMode"]; } - (void)setCurrentAppMode:(LKAppMode)appMode diff --git a/Session/Meta/Images.xcassets/Contents.json b/Session/Meta/Images.xcassets/Contents.json index da4a164c9..73c00596a 100644 --- a/Session/Meta/Images.xcassets/Contents.json +++ b/Session/Meta/Images.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Session/Meta/Images.xcassets/Loki V2/Gear.imageset/Contents.json b/Session/Meta/Images.xcassets/Loki V2/Gear.imageset/Contents.json deleted file mode 100644 index 94f23044f..000000000 --- a/Session/Meta/Images.xcassets/Loki V2/Gear.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "Gear.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Session/Meta/Images.xcassets/Loki V2/Gear.imageset/Gear.pdf b/Session/Meta/Images.xcassets/Loki V2/Gear.imageset/Gear.pdf deleted file mode 100644 index 751543bcd..000000000 Binary files a/Session/Meta/Images.xcassets/Loki V2/Gear.imageset/Gear.pdf and /dev/null differ diff --git a/Session/Meta/Images.xcassets/Loki/Contents.json b/Session/Meta/Images.xcassets/Loki/Contents.json index da4a164c9..73c00596a 100644 --- a/Session/Meta/Images.xcassets/Loki/Contents.json +++ b/Session/Meta/Images.xcassets/Loki/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json b/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json index 97010b1ad..93dc7834c 100644 --- a/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json +++ b/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json @@ -1,12 +1,12 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "crown.pdf" + "filename" : "crown.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Session/Meta/Images.xcassets/Loki/Crown.imageset/crown.pdf b/Session/Meta/Images.xcassets/Loki/Crown.imageset/crown.pdf index 5e5c424ca..3e067f0b5 100644 Binary files a/Session/Meta/Images.xcassets/Loki/Crown.imageset/crown.pdf and b/Session/Meta/Images.xcassets/Loki/Crown.imageset/crown.pdf differ diff --git a/Session/Meta/Images.xcassets/Loki V2/AddPerson.imageset/AddPerson.pdf b/Session/Meta/Images.xcassets/Session/AddPerson.imageset/AddPerson.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/AddPerson.imageset/AddPerson.pdf rename to Session/Meta/Images.xcassets/Session/AddPerson.imageset/AddPerson.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/AddPerson.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/AddPerson.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/AddPerson.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/AddPerson.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Session/ArrowUp.imageset/ArrowUp.pdf b/Session/Meta/Images.xcassets/Session/ArrowUp.imageset/ArrowUp.pdf new file mode 100644 index 000000000..fe36e100e --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/ArrowUp.imageset/ArrowUp.pdf @@ -0,0 +1,125 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 16.000000 2.500000 cm +0.000000 0.000000 0.000000 scn +-1.500000 3.000000 m +-1.500000 2.171572 -0.828427 1.500000 0.000000 1.500000 c +0.828427 1.500000 1.500000 2.171572 1.500000 3.000000 c +-1.500000 3.000000 l +h +1.500000 24.000000 m +1.500000 24.828426 0.828427 25.500000 0.000000 25.500000 c +-0.828427 25.500000 -1.500000 24.828426 -1.500000 24.000000 c +1.500000 24.000000 l +h +1.500000 3.000000 m +1.500000 24.000000 l +-1.500000 24.000000 l +-1.500000 3.000000 l +1.500000 3.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 7.000000 14.207108 cm +0.000000 0.000000 0.000000 scn +-1.060660 4.353553 m +-1.646447 3.767766 -1.646447 2.818019 -1.060660 2.232232 c +-0.474874 1.646446 0.474874 1.646446 1.060660 2.232232 c +-1.060660 4.353553 l +h +10.060660 11.232232 m +10.646446 11.818019 10.646446 12.767766 10.060660 13.353553 c +9.474874 13.939339 8.525126 13.939339 7.939340 13.353553 c +10.060660 11.232232 l +h +1.060660 2.232232 m +10.060660 11.232232 l +7.939340 13.353553 l +-1.060660 4.353553 l +1.060660 2.232232 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 16.000000 14.207108 cm +0.000000 0.000000 0.000000 scn +1.060660 13.353553 m +0.474874 13.939339 -0.474874 13.939339 -1.060660 13.353553 c +-1.646447 12.767766 -1.646447 11.818019 -1.060660 11.232232 c +1.060660 13.353553 l +h +7.939340 2.232232 m +8.525126 1.646445 9.474874 1.646445 10.060660 2.232232 c +10.646447 2.818019 10.646447 3.767766 10.060660 4.353552 c +7.939340 2.232232 l +h +-1.060660 11.232232 m +7.939340 2.232232 l +10.060660 4.353552 l +1.060660 13.353553 l +-1.060660 11.232232 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 1618 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 32.000000 32.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001708 00000 n +0000001731 00000 n +0000001904 00000 n +0000001978 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2037 +%%EOF \ No newline at end of file diff --git a/Session/Meta/Images.xcassets/Session/ArrowUp.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/ArrowUp.imageset/Contents.json new file mode 100644 index 000000000..3fc8d40ed --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/ArrowUp.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ArrowUp.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Loki V2/ArrowUpDarkMode.imageset/ArrowUp.pdf b/Session/Meta/Images.xcassets/Session/ArrowUpDarkMode.imageset/ArrowUp.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/ArrowUpDarkMode.imageset/ArrowUp.pdf rename to Session/Meta/Images.xcassets/Session/ArrowUpDarkMode.imageset/ArrowUp.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/ArrowUpDarkMode.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/ArrowUpDarkMode.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/ArrowUpDarkMode.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/ArrowUpDarkMode.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/ArrowUpLightMode.imageset/ArrowUpLightMode.pdf b/Session/Meta/Images.xcassets/Session/ArrowUpLightMode.imageset/ArrowUpLightMode.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/ArrowUpLightMode.imageset/ArrowUpLightMode.pdf rename to Session/Meta/Images.xcassets/Session/ArrowUpLightMode.imageset/ArrowUpLightMode.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/ArrowUpLightMode.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/ArrowUpLightMode.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/ArrowUpLightMode.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/ArrowUpLightMode.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/ChatBubbles.imageset/ChatBubbles.pdf b/Session/Meta/Images.xcassets/Session/ChatBubbles.imageset/ChatBubbles.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/ChatBubbles.imageset/ChatBubbles.pdf rename to Session/Meta/Images.xcassets/Session/ChatBubbles.imageset/ChatBubbles.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/ChatBubbles.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/ChatBubbles.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/ChatBubbles.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/ChatBubbles.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Check.imageset/Check.pdf b/Session/Meta/Images.xcassets/Session/Check.imageset/Check.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Check.imageset/Check.pdf rename to Session/Meta/Images.xcassets/Session/Check.imageset/Check.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Check.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Check.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Check.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Check.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Circle.imageset/Circle.pdf b/Session/Meta/Images.xcassets/Session/Circle.imageset/Circle.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Circle.imageset/Circle.pdf rename to Session/Meta/Images.xcassets/Session/Circle.imageset/Circle.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Circle.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Circle.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Circle.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Circle.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/CircleCheck.imageset/CircleCheck.pdf b/Session/Meta/Images.xcassets/Session/CircleCheck.imageset/CircleCheck.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CircleCheck.imageset/CircleCheck.pdf rename to Session/Meta/Images.xcassets/Session/CircleCheck.imageset/CircleCheck.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/CircleCheck.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/CircleCheck.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CircleCheck.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/CircleCheck.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/CircleDotDotDot.imageset/CircleDotDotDot.pdf b/Session/Meta/Images.xcassets/Session/CircleDotDotDot.imageset/CircleDotDotDot.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CircleDotDotDot.imageset/CircleDotDotDot.pdf rename to Session/Meta/Images.xcassets/Session/CircleDotDotDot.imageset/CircleDotDotDot.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/CircleDotDotDot.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/CircleDotDotDot.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CircleDotDotDot.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/CircleDotDotDot.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/CirclePause.imageset/CirclePause.pdf b/Session/Meta/Images.xcassets/Session/CirclePause.imageset/CirclePause.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CirclePause.imageset/CirclePause.pdf rename to Session/Meta/Images.xcassets/Session/CirclePause.imageset/CirclePause.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/CirclePause.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/CirclePause.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CirclePause.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/CirclePause.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/CirclePlay.imageset/CirclePlay.pdf b/Session/Meta/Images.xcassets/Session/CirclePlay.imageset/CirclePlay.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CirclePlay.imageset/CirclePlay.pdf rename to Session/Meta/Images.xcassets/Session/CirclePlay.imageset/CirclePlay.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/CirclePlay.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/CirclePlay.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CirclePlay.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/CirclePlay.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/CirclePlus.imageset/CirclePlus.pdf b/Session/Meta/Images.xcassets/Session/CirclePlus.imageset/CirclePlus.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CirclePlus.imageset/CirclePlus.pdf rename to Session/Meta/Images.xcassets/Session/CirclePlus.imageset/CirclePlus.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/CirclePlus.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/CirclePlus.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/CirclePlus.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/CirclePlus.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Contents.json b/Session/Meta/Images.xcassets/Session/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Contents.json rename to Session/Meta/Images.xcassets/Session/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckDarkMode.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/FilledCircleCheckDarkMode.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckDarkMode.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/FilledCircleCheckDarkMode.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckDarkMode.imageset/FilledCircleCheckDarkMode.pdf b/Session/Meta/Images.xcassets/Session/FilledCircleCheckDarkMode.imageset/FilledCircleCheckDarkMode.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckDarkMode.imageset/FilledCircleCheckDarkMode.pdf rename to Session/Meta/Images.xcassets/Session/FilledCircleCheckDarkMode.imageset/FilledCircleCheckDarkMode.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckLightMode.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/FilledCircleCheckLightMode.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckLightMode.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/FilledCircleCheckLightMode.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckLightMode.imageset/FilledCircleCheckLightMode.pdf b/Session/Meta/Images.xcassets/Session/FilledCircleCheckLightMode.imageset/FilledCircleCheckLightMode.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/FilledCircleCheckLightMode.imageset/FilledCircleCheckLightMode.pdf rename to Session/Meta/Images.xcassets/Session/FilledCircleCheckLightMode.imageset/FilledCircleCheckLightMode.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Flag.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Flag.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Flag.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Flag.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Flag.imageset/Flag.pdf b/Session/Meta/Images.xcassets/Session/Flag.imageset/Flag.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Flag.imageset/Flag.pdf rename to Session/Meta/Images.xcassets/Session/Flag.imageset/Flag.pdf diff --git a/Session/Meta/Images.xcassets/Session/Gear.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Gear.imageset/Contents.json new file mode 100644 index 000000000..a25957746 --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/Gear.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Gear.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Session/Gear.imageset/Gear.pdf b/Session/Meta/Images.xcassets/Session/Gear.imageset/Gear.pdf new file mode 100644 index 000000000..311252946 --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/Gear.imageset/Gear.pdf @@ -0,0 +1,203 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +12.753094 0.000000 m +11.246860 0.000000 l +10.028625 0.000000 9.037453 0.991125 9.037453 2.209358 c +9.037453 2.718937 l +8.519531 2.884407 8.016328 3.093281 7.532906 3.343452 c +7.171782 2.982327 l +6.297141 2.106609 4.896094 2.132298 4.046859 2.982656 c +2.982281 4.047188 l +2.131547 4.896984 2.107078 6.297564 2.982563 7.172110 c +3.343406 7.532953 l +3.093234 8.016376 2.884406 8.519484 2.718891 9.037499 c +2.209359 9.037499 l +0.991172 9.037499 0.000000 10.028625 0.000000 11.246861 c +0.000000 12.753140 l +0.000000 13.971375 0.991172 14.962500 2.209406 14.962500 c +2.718938 14.962500 l +2.884453 15.480469 3.093282 15.983624 3.343453 16.467047 c +2.982328 16.828125 l +2.107359 17.702156 2.131500 19.102875 2.982610 19.953047 c +4.047281 21.017672 l +4.898485 21.870047 6.299203 21.891329 7.172156 21.017391 c +7.532953 20.656593 l +8.016376 20.906719 8.519578 21.115593 9.037500 21.281109 c +9.037500 21.790640 l +9.037500 23.008875 10.028625 24.000000 11.246906 24.000000 c +12.753139 24.000000 l +13.971375 24.000000 14.962501 23.008875 14.962501 21.790640 c +14.962501 21.281063 l +15.480422 21.115593 15.983624 20.906719 16.467047 20.656548 c +16.828173 21.017672 l +17.702812 21.893391 19.103859 21.867702 19.953093 21.017344 c +21.017673 19.952812 l +21.868406 19.103016 21.892876 17.702438 21.017391 16.827890 c +20.656548 16.467047 l +20.906719 15.983624 21.115547 15.480515 21.281063 14.962500 c +21.790594 14.962500 l +23.008827 14.962500 24.000000 13.971375 24.000000 12.753140 c +24.000000 11.246861 l +24.000000 10.028625 23.008827 9.037499 21.790594 9.037499 c +21.281063 9.037499 l +21.115547 8.519530 20.906719 8.016376 20.656548 7.532953 c +21.017673 7.171827 l +21.892641 6.297796 21.868500 4.897079 21.017391 4.046907 c +19.952719 2.982281 l +19.101515 2.129908 17.700796 2.108625 16.827843 2.982563 c +16.467047 3.343361 l +15.983624 3.093235 15.480422 2.884359 14.962501 2.718845 c +14.962501 2.209267 l +14.962501 0.991125 13.971375 0.000000 12.753094 0.000000 c +12.753094 0.000000 l +h +7.767984 4.820156 m +8.439562 4.422983 9.162375 4.122936 9.916313 3.928360 c +10.226812 3.848249 10.443750 3.568218 10.443750 3.247547 c +10.443750 2.209358 l +10.443750 1.766531 10.804078 1.406250 11.246906 1.406250 c +12.753139 1.406250 l +13.195968 1.406250 13.556296 1.766531 13.556296 2.209358 c +13.556296 3.247547 l +13.556296 3.568218 13.773234 3.848249 14.083735 3.928360 c +14.837672 4.122936 15.560484 4.422983 16.232063 4.820156 c +16.508390 4.983562 16.860188 4.939125 17.087204 4.712109 c +17.822578 3.976688 l +18.139641 3.659250 18.648796 3.666611 18.958078 3.976360 c +20.023405 5.041641 l +20.331936 5.349796 20.342249 5.859047 20.023687 6.177187 c +19.287983 6.912891 l +19.061016 7.139860 19.016579 7.491703 19.179937 7.767984 c +19.577108 8.439516 19.877110 9.162329 20.071688 9.916313 c +20.151844 10.226812 20.431875 10.443704 20.752501 10.443704 c +21.790642 10.443704 l +22.233469 10.443704 22.593798 10.803985 22.593798 11.246813 c +22.593798 12.753094 l +22.593798 13.195922 22.233469 13.556204 21.790642 13.556204 c +20.752501 13.556204 l +20.431828 13.556204 20.151844 13.773141 20.071688 14.083593 c +19.877110 14.837578 19.577063 15.560390 19.179937 16.231922 c +19.016579 16.508204 19.061016 16.860046 19.287983 17.087015 c +20.023405 17.822437 l +20.341312 18.139969 20.333015 18.649031 20.023687 18.957985 c +18.958452 20.023218 l +18.649687 20.332407 18.140436 20.341454 17.822906 20.023500 c +17.087250 19.287796 l +16.860283 19.060781 16.508345 19.016344 16.232109 19.179750 c +15.560532 19.576921 14.837719 19.876968 14.083782 20.071547 c +13.773282 20.151657 13.556343 20.431688 13.556343 20.752359 c +13.556343 21.790640 l +13.556343 22.233469 13.196015 22.593750 12.753187 22.593750 c +11.246953 22.593750 l +10.804125 22.593750 10.443796 22.233469 10.443796 21.790640 c +10.443796 20.752453 l +10.443796 20.431782 10.226859 20.151751 9.916359 20.071640 c +9.162422 19.877062 8.439610 19.577015 7.768031 19.179844 c +7.491656 19.016483 7.139860 19.060921 6.912891 19.287891 c +6.177516 20.023312 l +5.860453 20.340750 5.351250 20.333391 5.042016 20.023640 c +3.976687 18.958359 l +3.668156 18.650204 3.657844 18.141001 3.976406 17.822813 c +4.712110 17.087109 l +4.939078 16.860140 4.983516 16.508297 4.820156 16.232016 c +4.422985 15.560485 4.122984 14.837671 3.928406 14.083687 c +3.848250 13.773188 3.568219 13.556297 3.247594 13.556297 c +2.209406 13.556297 l +1.766578 13.556251 1.406250 13.195969 1.406250 12.753140 c +1.406250 11.246861 l +1.406250 10.804032 1.766578 10.443749 2.209406 10.443749 c +3.247547 10.443749 l +3.568219 10.443749 3.848203 10.226813 3.928360 9.916360 c +4.122938 9.162375 4.422985 8.439562 4.820109 7.768030 c +4.983469 7.491749 4.939031 7.139906 4.712063 6.912937 c +3.976641 6.177515 l +3.658734 5.859983 3.667031 5.350922 3.976359 5.041969 c +5.041594 3.976734 l +5.350359 3.667547 5.859610 3.658499 6.177141 3.976452 c +6.912797 4.712156 l +7.080047 4.879360 7.428000 5.021250 7.767984 4.820156 c +7.767984 4.820156 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 6.778137 6.778126 cm +0.000000 0.000000 0.000000 scn +5.221874 0.000000 m +2.342484 0.000000 0.000000 2.342530 0.000000 5.221874 c +0.000000 8.101217 2.342484 10.443748 5.221874 10.443748 c +8.101265 10.443748 10.443748 8.101217 10.443748 5.221874 c +10.443748 2.342530 8.101265 0.000000 5.221874 0.000000 c +5.221874 0.000000 l +h +5.221874 9.037498 m +3.117890 9.037498 1.406250 7.325811 1.406250 5.221874 c +1.406250 3.117937 3.117937 1.406250 5.221874 1.406250 c +7.325811 1.406250 9.037498 3.117937 9.037498 5.221874 c +9.037498 7.325811 7.325859 9.037498 5.221874 9.037498 c +5.221874 9.037498 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 5666 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000005756 00000 n +0000005779 00000 n +0000005952 00000 n +0000006026 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +6085 +%%EOF \ No newline at end of file diff --git a/Session/Meta/Images.xcassets/Loki V2/Globe.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Globe.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Globe.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Globe.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Globe.imageset/Globe.pdf b/Session/Meta/Images.xcassets/Session/Globe.imageset/Globe.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Globe.imageset/Globe.pdf rename to Session/Meta/Images.xcassets/Session/Globe.imageset/Globe.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Group.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Group.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Group.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Group.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Group.imageset/Group.pdf b/Session/Meta/Images.xcassets/Session/Group.imageset/Group.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Group.imageset/Group.pdf rename to Session/Meta/Images.xcassets/Session/Group.imageset/Group.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Key.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Key.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Key.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Key.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Key.imageset/Key.pdf b/Session/Meta/Images.xcassets/Session/Key.imageset/Key.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Key.imageset/Key.pdf rename to Session/Meta/Images.xcassets/Session/Key.imageset/Key.pdf diff --git a/Session/Meta/Images.xcassets/Session/Link.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Link.imageset/Contents.json new file mode 100644 index 000000000..167f29083 --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/Link.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Link.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/Session/Link.imageset/Link.pdf b/Session/Meta/Images.xcassets/Session/Link.imageset/Link.pdf new file mode 100644 index 000000000..e3604b1c8 --- /dev/null +++ b/Session/Meta/Images.xcassets/Session/Link.imageset/Link.pdf @@ -0,0 +1,182 @@ +%PDF-1.7 + +1 0 obj + << /BBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources << >> + /Subtype /Form + /Length 2 0 R + /Group << /Type /Group + /S /Transparency + >> + /Type /XObject + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 -1.389473 cm +0.000000 0.000000 0.000000 scn +15.309964 16.699244 m +18.110605 13.895697 18.072166 9.400900 15.326838 6.640337 c +15.321682 6.634712 15.315589 6.628616 15.309964 6.622992 c +12.159963 3.472992 l +9.381680 0.694710 4.861571 0.695086 2.083711 3.472992 c +-0.694571 6.250805 -0.694571 10.771430 2.083711 13.549243 c +3.823056 15.288588 l +4.284306 15.749838 5.078650 15.443274 5.102462 14.791431 c +5.132837 13.960712 5.281806 13.126102 5.556681 12.320134 c +5.649775 12.047228 5.583260 11.745353 5.379353 11.541447 c +4.765900 10.927994 l +3.452181 9.614275 3.410977 7.475180 4.711759 6.148617 c +6.025384 4.808975 8.184541 4.801008 9.508245 6.124712 c +12.658244 9.274244 l +13.979698 10.595697 13.974167 12.731603 12.658244 14.047524 c +12.484760 14.220681 12.310010 14.355212 12.173510 14.449197 c +12.076943 14.515509 11.997212 14.603470 11.940674 14.706064 c +11.884135 14.808659 11.852356 14.923047 11.847870 15.040103 c +11.829308 15.535432 12.004807 16.045853 12.396214 16.437260 c +13.383121 17.424213 l +13.641917 17.683010 14.047901 17.714790 14.347995 17.505354 c +14.691668 17.265377 15.013565 16.995634 15.309964 16.699244 c +15.309964 16.699244 l +h +21.916294 23.305902 m +19.138433 26.083809 14.618324 26.084185 11.840042 23.305902 c +8.690041 20.155901 l +8.684416 20.150276 8.678322 20.144184 8.673166 20.138557 c +5.927884 17.377995 5.889400 12.883197 8.690041 10.079649 c +8.986423 9.783272 9.308305 9.513546 9.651963 9.273586 c +9.952058 9.064150 10.358088 9.095978 10.616838 9.354727 c +11.603745 10.341681 l +11.995152 10.733087 12.170650 11.243508 12.152088 11.738837 c +12.147602 11.855893 12.115823 11.970282 12.059285 12.072876 c +12.002747 12.175470 11.923014 12.263433 11.826448 12.329744 c +11.689948 12.423729 11.515198 12.558260 11.341714 12.731416 c +10.025791 14.047338 10.020260 16.183245 11.341714 17.504698 c +14.491714 20.654230 l +15.815417 21.977934 17.974527 21.969965 19.288198 20.630323 c +20.588980 19.303761 20.547825 17.164665 19.234058 15.850946 c +18.620605 15.237494 l +18.416700 15.033587 18.350183 14.731712 18.443277 14.458806 c +18.718151 13.652838 18.867121 12.818229 18.897495 11.987510 c +18.921356 11.335666 19.715652 11.029102 20.176903 11.490353 c +21.916246 13.229697 l +24.694574 16.007463 24.694576 20.528090 21.916294 23.305902 c +21.916294 23.305902 l +h +f +n +Q + +endstream +endobj + +2 0 obj + 2367 +endobj + +3 0 obj + << /BBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources << >> + /Subtype /Form + /Length 4 0 R + /Group << /Type /Group + /S /Transparency + >> + /Type /XObject + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +0.000000 24.000000 m +24.000000 24.000000 l +24.000000 0.000000 l +0.000000 0.000000 l +0.000000 24.000000 l +h +f +n +Q + +endstream +endobj + +4 0 obj + 232 +endobj + +5 0 obj + << /XObject << /X1 1 0 R >> + /ExtGState << /E1 << /SMask << /Type /Mask + /G 3 0 R + /S /Alpha + >> + /Type /ExtGState + >> >> + >> +endobj + +6 0 obj + << /Length 7 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +/E1 gs +/X1 Do +Q + +endstream +endobj + +7 0 obj + 46 +endobj + +8 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 5 0 R + /Contents 6 0 R + /Parent 9 0 R + >> +endobj + +9 0 obj + << /Kids [ 8 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +10 0 obj + << /Type /Catalog + /Pages 9 0 R + >> +endobj + +xref +0 11 +0000000000 65535 f +0000000010 00000 n +0000002625 00000 n +0000002648 00000 n +0000003128 00000 n +0000003150 00000 n +0000003448 00000 n +0000003550 00000 n +0000003571 00000 n +0000003744 00000 n +0000003818 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 10 0 R + /Size 11 +>> +startxref +3878 +%%EOF \ No newline at end of file diff --git a/Session/Meta/Images.xcassets/Loki V2/MagnifyingGlass.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/MagnifyingGlass.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/MagnifyingGlass.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/MagnifyingGlass.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/MagnifyingGlass.imageset/MagnifyingGlass.pdf b/Session/Meta/Images.xcassets/Session/MagnifyingGlass.imageset/MagnifyingGlass.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/MagnifyingGlass.imageset/MagnifyingGlass.pdf rename to Session/Meta/Images.xcassets/Session/MagnifyingGlass.imageset/MagnifyingGlass.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Message.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Message.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Message.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Message.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Message.imageset/Message.pdf b/Session/Meta/Images.xcassets/Session/Message.imageset/Message.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Message.imageset/Message.pdf rename to Session/Meta/Images.xcassets/Session/Message.imageset/Message.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Microphone.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Microphone.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Microphone.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Microphone.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Microphone.imageset/Microphone.pdf b/Session/Meta/Images.xcassets/Session/Microphone.imageset/Microphone.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Microphone.imageset/Microphone.pdf rename to Session/Meta/Images.xcassets/Session/Microphone.imageset/Microphone.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Mute.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Mute.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Mute.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Mute.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Mute.imageset/Mute.pdf b/Session/Meta/Images.xcassets/Session/Mute.imageset/Mute.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Mute.imageset/Mute.pdf rename to Session/Meta/Images.xcassets/Session/Mute.imageset/Mute.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Pause.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Pause.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Pause.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Pause.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Pause.imageset/Pause.pdf b/Session/Meta/Images.xcassets/Session/Pause.imageset/Pause.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Pause.imageset/Pause.pdf rename to Session/Meta/Images.xcassets/Session/Pause.imageset/Pause.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/People.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/People.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/People.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/People.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/People.imageset/People.pdf b/Session/Meta/Images.xcassets/Session/People.imageset/People.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/People.imageset/People.pdf rename to Session/Meta/Images.xcassets/Session/People.imageset/People.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Play.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Play.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Play.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Play.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Play.imageset/Play.pdf b/Session/Meta/Images.xcassets/Session/Play.imageset/Play.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Play.imageset/Play.pdf rename to Session/Meta/Images.xcassets/Session/Play.imageset/Play.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Plus.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Plus.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Plus.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Plus.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Plus.imageset/Plus.pdf b/Session/Meta/Images.xcassets/Session/Plus.imageset/Plus.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Plus.imageset/Plus.pdf rename to Session/Meta/Images.xcassets/Session/Plus.imageset/Plus.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/QRCode.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/QRCode.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/QRCode.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/QRCode.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/QRCode.imageset/QRCodeFilled.pdf b/Session/Meta/Images.xcassets/Session/QRCode.imageset/QRCodeFilled.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/QRCode.imageset/QRCodeFilled.pdf rename to Session/Meta/Images.xcassets/Session/QRCode.imageset/QRCodeFilled.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/QuestionMark.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/QuestionMark.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/QuestionMark.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/QuestionMark.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/QuestionMark.imageset/QuestionMark.pdf b/Session/Meta/Images.xcassets/Session/QuestionMark.imageset/QuestionMark.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/QuestionMark.imageset/QuestionMark.pdf rename to Session/Meta/Images.xcassets/Session/QuestionMark.imageset/QuestionMark.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/SessionGreen32.png b/Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/SessionGreen32.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/SessionGreen32.png rename to Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/SessionGreen32.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/SessionGreen32@2x.png b/Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/SessionGreen32@2x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/SessionGreen32@2x.png rename to Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/SessionGreen32@2x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/SessionGreen32@3x.png b/Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/SessionGreen32@3x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen32.imageset/SessionGreen32@3x.png rename to Session/Meta/Images.xcassets/Session/SessionGreen32.imageset/SessionGreen32@3x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/SessionGreen64.png b/Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/SessionGreen64.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/SessionGreen64.png rename to Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/SessionGreen64.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/SessionGreen64@2x.png b/Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/SessionGreen64@2x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/SessionGreen64@2x.png rename to Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/SessionGreen64@2x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/SessionGreen64@3x.png b/Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/SessionGreen64@3x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionGreen64.imageset/SessionGreen64@3x.png rename to Session/Meta/Images.xcassets/Session/SessionGreen64.imageset/SessionGreen64@3x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/SessionWhite16.png b/Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/SessionWhite16.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/SessionWhite16.png rename to Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/SessionWhite16.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/SessionWhite16@2x.png b/Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/SessionWhite16@2x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/SessionWhite16@2x.png rename to Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/SessionWhite16@2x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/SessionWhite16@3x.png b/Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/SessionWhite16@3x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite16.imageset/SessionWhite16@3x.png rename to Session/Meta/Images.xcassets/Session/SessionWhite16.imageset/SessionWhite16@3x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/SessionWhite24.png b/Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/SessionWhite24.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/SessionWhite24.png rename to Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/SessionWhite24.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/SessionWhite24@2x.png b/Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/SessionWhite24@2x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/SessionWhite24@2x.png rename to Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/SessionWhite24@2x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/SessionWhite24@3x.png b/Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/SessionWhite24@3x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite24.imageset/SessionWhite24@3x.png rename to Session/Meta/Images.xcassets/Session/SessionWhite24.imageset/SessionWhite24@3x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/SessionWhite40.png b/Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/SessionWhite40.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/SessionWhite40.png rename to Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/SessionWhite40.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/SessionWhite40@2x.png b/Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/SessionWhite40@2x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/SessionWhite40@2x.png rename to Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/SessionWhite40@2x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/SessionWhite40@3x.png b/Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/SessionWhite40@3x.png similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/SessionWhite40.imageset/SessionWhite40@3x.png rename to Session/Meta/Images.xcassets/Session/SessionWhite40.imageset/SessionWhite40@3x.png diff --git a/Session/Meta/Images.xcassets/Loki V2/Shield.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Shield.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Shield.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Shield.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Shield.imageset/Shield.pdf b/Session/Meta/Images.xcassets/Session/Shield.imageset/Shield.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Shield.imageset/Shield.pdf rename to Session/Meta/Images.xcassets/Session/Shield.imageset/Shield.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Star.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Star.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Star.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Star.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Star.imageset/StarOutline.pdf b/Session/Meta/Images.xcassets/Session/Star.imageset/StarOutline.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Star.imageset/StarOutline.pdf rename to Session/Meta/Images.xcassets/Session/Star.imageset/StarOutline.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/Sun.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/Sun.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Sun.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/Sun.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/Sun.imageset/Sun.pdf b/Session/Meta/Images.xcassets/Session/Sun.imageset/Sun.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/Sun.imageset/Sun.pdf rename to Session/Meta/Images.xcassets/Session/Sun.imageset/Sun.pdf diff --git a/Session/Meta/Images.xcassets/Loki V2/X.imageset/Contents.json b/Session/Meta/Images.xcassets/Session/X.imageset/Contents.json similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/X.imageset/Contents.json rename to Session/Meta/Images.xcassets/Session/X.imageset/Contents.json diff --git a/Session/Meta/Images.xcassets/Loki V2/X.imageset/X.pdf b/Session/Meta/Images.xcassets/Session/X.imageset/X.pdf similarity index 100% rename from Session/Meta/Images.xcassets/Loki V2/X.imageset/X.pdf rename to Session/Meta/Images.xcassets/Session/X.imageset/X.pdf diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 900f868e9..c95439b0d 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -8,13 +8,12 @@ // Separate iOS Frameworks from other imports. #import "AppDelegate.h" -#import "AVAudioSession+OWS.h" -#import "AttachmentUploadView.h" #import "AvatarViewHelper.h" +#import "AVAudioSession+OWS.h" #import "ContactCellView.h" #import "ContactTableViewCell.h" -#import "ConversationViewCell.h" #import "ConversationViewItem.h" +#import "ConversationViewModel.h" #import "DateUtil.h" #import "MediaDetailViewController.h" #import "NotificationSettingsViewController.h" @@ -23,14 +22,11 @@ #import "OWSBackup.h" #import "OWSBackupIO.h" #import "OWSBezierPathView.h" -#import "OWSBubbleShapeView.h" -#import "OWSBubbleView.h" +#import "OWSConversationSettingsViewController.h" #import "OWSDatabaseMigration.h" -#import "OWSMessageBubbleView.h" -#import "OWSMessageCell.h" +#import "OWSMessageTimerView.h" #import "OWSNavigationController.h" #import "OWSProgressView.h" -#import "OWSQuotedMessageView.h" #import "OWSWindowManager.h" #import "PrivacySettingsTableViewController.h" #import "OWSQRCodeScanningViewController.h" diff --git a/Session/Meta/SignalApp.h b/Session/Meta/SignalApp.h index 190dc2333..8583ed672 100644 --- a/Session/Meta/SignalApp.h +++ b/Session/Meta/SignalApp.h @@ -2,7 +2,7 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import "ConversationViewController.h" +#import "ConversationViewAction.h" NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Meta/SignalApp.m b/Session/Meta/SignalApp.m index 21dd15d32..4ddb9c63d 100644 --- a/Session/Meta/SignalApp.m +++ b/Session/Meta/SignalApp.m @@ -4,7 +4,6 @@ #import "SignalApp.h" #import "AppDelegate.h" -#import "ConversationViewController.h" #import "Session-Swift.h" #import #import @@ -105,16 +104,6 @@ NS_ASSUME_NONNULL_BEGIN } DispatchMainThreadSafe(^{ - UIViewController *frontmostVC = [[UIApplication sharedApplication] frontmostViewController]; - - if ([frontmostVC isKindOfClass:[ConversationViewController class]]) { - ConversationViewController *conversationVC = (ConversationViewController *)frontmostVC; - if ([conversationVC.thread.uniqueId isEqualToString:thread.uniqueId]) { - [conversationVC popKeyBoard]; - return; - } - } - [self.homeViewController show:thread with:action highlightedMessageID:focusMessageId animated:isAnimated]; }); } @@ -133,16 +122,6 @@ NS_ASSUME_NONNULL_BEGIN } DispatchMainThreadSafe(^{ - UIViewController *frontmostVC = [[UIApplication sharedApplication] frontmostViewController]; - - if ([frontmostVC isKindOfClass:[ConversationViewController class]]) { - ConversationViewController *conversationVC = (ConversationViewController *)frontmostVC; - if ([conversationVC.thread.uniqueId isEqualToString:thread.uniqueId]) { - [conversationVC scrollToFirstUnreadMessage:isAnimated]; - return; - } - } - [self.homeViewController show:thread with:ConversationViewActionNone highlightedMessageID:nil animated:isAnimated]; }); } diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index df1d622a1..07d369ebe 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -188,7 +188,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { return true } - guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationViewController else { + guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else { return true } diff --git a/Session/Onboarding/PNOptionView.swift b/Session/Onboarding/PNOptionView.swift index e412c4acd..f9623f62c 100644 --- a/Session/Onboarding/PNOptionView.swift +++ b/Session/Onboarding/PNOptionView.swift @@ -29,7 +29,7 @@ final class OptionView : UIView { // Round corners layer.cornerRadius = Values.pnOptionCornerRadius // Set up border - layer.borderWidth = Values.borderThickness + layer.borderWidth = 1 layer.borderColor = Colors.pnOptionBorder.cgColor // Set up shadow layer.shadowColor = UIColor.black.cgColor @@ -88,7 +88,7 @@ final class OptionView : UIView { layer.add(borderAnimation, forKey: borderAnimation.keyPath) layer.borderColor = newBorderColor // Animate shadow color - let newShadowColor = isSelected ? Colors.newConversationButtonShadow.cgColor : UIColor.black.cgColor + let newShadowColor = isSelected ? Colors.expandedButtonGlowColor.cgColor : UIColor.black.cgColor let shadowAnimation = CABasicAnimation(keyPath: "shadowColor") shadowAnimation.fromValue = layer.shadowColor shadowAnimation.toValue = newShadowColor diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index b6f2f651c..f0803ecab 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -64,8 +64,8 @@ final class RegisterVC : BaseVC { let publicKeyLabelContainer = UIView() publicKeyLabelContainer.addSubview(publicKeyLabel) publicKeyLabel.pin(to: publicKeyLabelContainer, withInset: Values.mediumSpacing) - publicKeyLabelContainer.layer.cornerRadius = Values.textFieldCornerRadius - publicKeyLabelContainer.layer.borderWidth = Values.borderThickness + publicKeyLabelContainer.layer.cornerRadius = TextField.cornerRadius + publicKeyLabelContainer.layer.borderWidth = 1 publicKeyLabelContainer.layer.borderColor = Colors.text.cgColor // Set up spacers let topSpacer = UIView.vStretchingSpacer() diff --git a/Session/Onboarding/SeedReminderView.swift b/Session/Onboarding/SeedReminderView.swift index 0ff748461..aaa06700e 100644 --- a/Session/Onboarding/SeedReminderView.swift +++ b/Session/Onboarding/SeedReminderView.swift @@ -25,7 +25,7 @@ final class SeedReminderView : UIView { lazy var subtitleLabel: UILabel = { let result = UILabel() - result.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) result.font = .systemFont(ofSize: Values.verySmallFontSize) result.lineBreakMode = .byWordWrapping result.numberOfLines = 0 diff --git a/Session/Onboarding/SeedVC.swift b/Session/Onboarding/SeedVC.swift index 558810625..3c360316b 100644 --- a/Session/Onboarding/SeedVC.swift +++ b/Session/Onboarding/SeedVC.swift @@ -82,12 +82,12 @@ final class SeedVC : BaseVC { let mnemonicLabelContainer = UIView() mnemonicLabelContainer.addSubview(mnemonicLabel) mnemonicLabel.pin(to: mnemonicLabelContainer, withInset: isIPhone6OrSmaller ? Values.smallSpacing : Values.mediumSpacing) - mnemonicLabelContainer.layer.cornerRadius = Values.textFieldCornerRadius - mnemonicLabelContainer.layer.borderWidth = Values.borderThickness + mnemonicLabelContainer.layer.cornerRadius = TextField.cornerRadius + mnemonicLabelContainer.layer.borderWidth = 1 mnemonicLabelContainer.layer.borderColor = Colors.text.cgColor // Set up call to action label let callToActionLabel = UILabel() - callToActionLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + callToActionLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) callToActionLabel.font = .systemFont(ofSize: isIPhone5OrSmaller ? Values.smallFontSize : Values.mediumFontSize) callToActionLabel.text = NSLocalizedString("vc_seed_reveal_button_title", comment: "") callToActionLabel.textAlignment = .center diff --git a/Session/Open Groups/JoinPublicChatVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift similarity index 98% rename from Session/Open Groups/JoinPublicChatVC.swift rename to Session/Open Groups/JoinOpenGroupVC.swift index 1e2b94fef..2c100e17b 100644 --- a/Session/Open Groups/JoinPublicChatVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -1,5 +1,5 @@ -final class JoinPublicChatVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { +final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) private var pages: [UIViewController] = [] private var isJoining = false @@ -163,7 +163,7 @@ final class JoinPublicChatVC : BaseVC, UIPageViewControllerDataSource, UIPageVie } private final class EnterChatURLVC : UIViewController { - weak var joinPublicChatVC: JoinPublicChatVC! + weak var joinPublicChatVC: JoinOpenGroupVC! private var bottomConstraint: NSLayoutConstraint! // MARK: Components @@ -250,7 +250,7 @@ private final class EnterChatURLVC : UIViewController { } private final class ScanQRCodePlaceholderVC : UIViewController { - weak var joinPublicChatVC: JoinPublicChatVC! + weak var joinPublicChatVC: JoinOpenGroupVC! override func viewDidLoad() { // Remove background color diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 57e3d7bb5..925a71353 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -44,7 +44,7 @@ final class PathVC : BaseVC { private func setUpViewHierarchy() { // Set up explanation label let explanationLabel = UILabel() - explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.text = NSLocalizedString("vc_path_explanation", comment: "") explanationLabel.numberOfLines = 0 diff --git a/Session/Settings/AboutTableViewController.h b/Session/Settings/AboutTableViewController.h deleted file mode 100644 index ee8bbf74c..000000000 --- a/Session/Settings/AboutTableViewController.h +++ /dev/null @@ -1,9 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSTableViewController.h" - -@interface AboutTableViewController : OWSTableViewController - -@end diff --git a/Session/Settings/AboutTableViewController.m b/Session/Settings/AboutTableViewController.m deleted file mode 100644 index 451eb379c..000000000 --- a/Session/Settings/AboutTableViewController.m +++ /dev/null @@ -1,162 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "AboutTableViewController.h" -#import "Session-Swift.h" -#import "UIView+OWS.h" -#import -#import -#import -#import -#import - -@implementation AboutTableViewController - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.title = NSLocalizedString(@"SETTINGS_ABOUT", @"Navbar title"); - - [self updateTableContents]; - - // Crash app if user performs obscure gesture in order to test - // crash reporting. - UITapGestureRecognizer *gesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(crashApp)]; - gesture.numberOfTouchesRequired = 2; - gesture.numberOfTapsRequired = 5; - [self.tableView addGestureRecognizer:gesture]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(pushTokensDidChange:) - name:[OWSSyncPushTokensJob PushTokensDidChange] - object:nil]; -} - -- (void)pushTokensDidChange:(NSNotification *)notification -{ - [self updateTableContents]; -} - -#pragma mark - Table Contents - -- (void)updateTableContents -{ - OWSTableContents *contents = [OWSTableContents new]; - - OWSTableSection *informationSection = [OWSTableSection new]; - informationSection.headerTitle = NSLocalizedString(@"SETTINGS_INFORMATION_HEADER", @""); - [informationSection addItem:[OWSTableItem labelItemWithText:NSLocalizedString(@"SETTINGS_VERSION", @"") - accessoryText:[[[NSBundle mainBundle] infoDictionary] - objectForKey:@"CFBundleVersion"]]]; - - [informationSection addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"SETTINGS_LEGAL_TERMS_CELL", - @"table cell label") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"terms") - actionBlock:^{ - [[UIApplication sharedApplication] - openURL:[NSURL URLWithString:kLegalTermsUrlString]]; - }]]; - - [contents addSection:informationSection]; - - OWSTableSection *helpSection = [OWSTableSection new]; - helpSection.headerTitle = NSLocalizedString(@"SETTINGS_HELP_HEADER", @""); - [helpSection addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"SETTINGS_SUPPORT", @"") - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"support") - actionBlock:^{ - [[UIApplication sharedApplication] - openURL:[NSURL URLWithString:@"https://support.signal.org"]]; - }]]; - [contents addSection:helpSection]; - - UILabel *copyrightLabel = [UILabel new]; - copyrightLabel.text = NSLocalizedString(@"SETTINGS_COPYRIGHT", @""); - copyrightLabel.textColor = [Theme secondaryColor]; - copyrightLabel.font = [UIFont ows_regularFontWithSize:15.0f]; - copyrightLabel.numberOfLines = 2; - copyrightLabel.lineBreakMode = NSLineBreakByWordWrapping; - copyrightLabel.textAlignment = NSTextAlignmentCenter; - helpSection.customFooterView = copyrightLabel; - helpSection.customFooterHeight = @(60.f); - -#ifdef DEBUG - __block NSUInteger threadCount; - __block NSUInteger messageCount; - __block NSUInteger attachmentCount; - [OWSPrimaryStorage.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - threadCount = [transaction numberOfKeysInCollection:[TSThread collection]]; - messageCount = [transaction numberOfKeysInCollection:[TSInteraction collection]]; - attachmentCount = [transaction numberOfKeysInCollection:[TSAttachment collection]]; - }]; - - NSByteCountFormatter *byteCountFormatter = [NSByteCountFormatter new]; - - // format counts with thousands separator - NSNumberFormatter *numberFormatter = [NSNumberFormatter new]; - numberFormatter.formatterBehavior = NSNumberFormatterBehavior10_4; - numberFormatter.numberStyle = NSNumberFormatterDecimalStyle; - - OWSTableSection *debugSection = [OWSTableSection new]; - - debugSection.headerTitle = @"Debug"; - - NSString *formattedThreadCount = [numberFormatter stringFromNumber:@(threadCount)]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Threads: %@", formattedThreadCount]]]; - - NSString *formattedMessageCount = [numberFormatter stringFromNumber:@(messageCount)]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Messages: %@", formattedMessageCount]]]; - - NSString *formattedAttachmentCount = [numberFormatter stringFromNumber:@(attachmentCount)]; - [debugSection addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Attachments: %@", - formattedAttachmentCount]]]; - - NSString *dbSize = - [byteCountFormatter stringFromByteCount:(long long)[OWSPrimaryStorage.sharedManager databaseFileSize]]; - [debugSection addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Database size: %@", dbSize]]]; - - NSString *dbWALSize = - [byteCountFormatter stringFromByteCount:(long long)[OWSPrimaryStorage.sharedManager databaseWALFileSize]]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Database WAL size: %@", dbWALSize]]]; - - NSString *dbSHMSize = - [byteCountFormatter stringFromByteCount:(long long)[OWSPrimaryStorage.sharedManager databaseSHMFileSize]]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Database SHM size: %@", dbSHMSize]]]; - - [contents addSection:debugSection]; - - OWSPreferences *preferences = Environment.shared.preferences; - NSString *_Nullable pushToken = [preferences getPushToken]; - NSString *_Nullable voipToken = [preferences getVoipToken]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Push Token: %@", pushToken ?: @"None"]]]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"VOIP Token: %@", voipToken ?: @"None"]]]; - - // Strip prefix from category, otherwise it's too long to fit into cell on a small device. - NSString *audioCategory = - [AVAudioSession.sharedInstance.category stringByReplacingOccurrencesOfString:@"AVAudioSessionCategory" - withString:@""]; - [debugSection - addItem:[OWSTableItem labelItemWithText:[NSString stringWithFormat:@"Audio Category: %@", audioCategory]]]; -#endif - - self.contents = contents; -} - -- (void)crashApp -{ - OWSFail(@"crashApp"); -} - -@end diff --git a/Session/Settings/MultiDeviceVC.swift b/Session/Settings/MultiDeviceVC.swift index 60fc933d5..3a86e5b15 100644 --- a/Session/Settings/MultiDeviceVC.swift +++ b/Session/Settings/MultiDeviceVC.swift @@ -104,8 +104,8 @@ final class MultiDeviceVC : BaseVC { let mnemonicLabelContainer = UIView() mnemonicLabelContainer.addSubview(mnemonicLabel) mnemonicLabel.pin(to: mnemonicLabelContainer, withInset: isIPhone6OrSmaller ? 4 : Values.smallSpacing) - mnemonicLabelContainer.layer.cornerRadius = Values.textFieldCornerRadius - mnemonicLabelContainer.layer.borderWidth = Values.borderThickness + mnemonicLabelContainer.layer.cornerRadius = TextField.cornerRadius + mnemonicLabelContainer.layer.borderWidth = 1 mnemonicLabelContainer.layer.borderColor = Colors.text.cgColor let stepsLabel1Container = UIView() stepsLabel1Container.addSubview(stepsLabel1) diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 15ca1da09..413cd0745 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -14,7 +14,7 @@ final class NukeDataModal : Modal { titleLabel.textAlignment = .center // Set up explanation label let explanationLabel = UILabel() - explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.text = NSLocalizedString("modal_clear_all_data_explanation", comment: "") explanationLabel.numberOfLines = 0 diff --git a/Session/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index 6fb711b9b..81f2f18a4 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -253,69 +253,11 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s [self.typingIndicators setTypingIndicatorsEnabledWithValue:enabled]; } -- (void)didToggleCallsHideIPAddressSwitch:(UISwitch *)sender -{ - BOOL enabled = sender.isOn; - OWSLogInfo(@"toggled callsHideIPAddress: %@", enabled ? @"ON" : @"OFF"); - [self.preferences setDoCallsHideIPAddress:enabled]; -} - -- (void)didToggleEnableSystemCallLogSwitch:(UISwitch *)sender -{ - OWSLogInfo(@"user toggled call kit preference: %@", (sender.isOn ? @"ON" : @"OFF")); - [self.preferences setIsSystemCallLogEnabled:sender.isOn]; -} - -- (void)didToggleEnableCallKitSwitch:(UISwitch *)sender -{ - OWSLogInfo(@"user toggled call kit preference: %@", (sender.isOn ? @"ON" : @"OFF")); - [self.preferences setIsCallKitEnabled:sender.isOn]; - - // Show/Hide dependent switch: CallKit privacy - [self updateTableContents]; -} - -- (void)didToggleEnableCallKitPrivacySwitch:(UISwitch *)sender -{ - OWSLogInfo(@"user toggled call kit privacy preference: %@", (sender.isOn ? @"ON" : @"OFF")); - [self.preferences setIsCallKitPrivacyEnabled:!sender.isOn]; - - // rebuild callUIAdapter since CallKit configuration changed. -// [AppEnvironment.shared.callService createCallUIAdapter]; -} - -- (void)didToggleUDUnrestrictedAccessSwitch:(UISwitch *)sender -{ - -} - -- (void)didToggleUDShowIndicatorsSwitch:(UISwitch *)sender -{ - OWSLogInfo(@"toggled to: %@", (sender.isOn ? @"ON" : @"OFF")); - [self.preferences setShouldShowUnidentifiedDeliveryIndicators:sender.isOn]; -} - - (void)didToggleLinkPreviewsEnabled:(UISwitch *)sender { - BOOL isOn = sender.isOn; - if (isOn) { - NSString *title = @"Enable Link Previews?"; - NSString *message = @"You will not have full metadata protection when sending or receiving link previews."; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:nil]]; - [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"cancel", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - [sender setOn:NO animated:YES]; - SSKPreferences.areLinkPreviewsEnabled = NO; - }]]; - [self presentViewController:alert animated:YES completion:nil]; - } - OWSLogInfo(@"toggled to: %@", (sender.isOn ? @"ON" : @"OFF")); - SSKPreferences.areLinkPreviewsEnabled = sender.isOn; -} - -- (void)show2FASettings -{ - + BOOL enabled = sender.isOn; + OWSLogInfo(@"toggled to: %@", (enabled ? @"ON" : @"OFF")); + SSKPreferences.areLinkPreviewsEnabled = enabled; } - (void)isScreenLockEnabledDidChange:(UISwitch *)sender diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index d5dd02e53..1aae18602 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -34,12 +34,12 @@ final class SeedModal : Modal { let mnemonicLabelContainer = UIView() mnemonicLabelContainer.addSubview(mnemonicLabel) mnemonicLabel.pin(to: mnemonicLabelContainer, withInset: isIPhone6OrSmaller ? 4 : Values.smallSpacing) - mnemonicLabelContainer.layer.cornerRadius = Values.textFieldCornerRadius - mnemonicLabelContainer.layer.borderWidth = Values.borderThickness + mnemonicLabelContainer.layer.cornerRadius = TextField.cornerRadius + mnemonicLabelContainer.layer.borderWidth = 1 mnemonicLabelContainer.layer.borderColor = Colors.text.cgColor // Set up explanation label let explanationLabel = UILabel() - explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.text = NSLocalizedString("modal_seed_explanation", comment: "") explanationLabel.numberOfLines = 0 diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index 3c93fe9fa..53fbc0c72 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -119,7 +119,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { } // Set up version label let versionLabel = UILabel() - versionLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + versionLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) versionLabel.font = .systemFont(ofSize: Values.verySmallFontSize) versionLabel.numberOfLines = 0 versionLabel.textAlignment = .center diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index ed234fead..f722f68fc 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -30,7 +30,7 @@ class BaseVC : UIViewController { internal func setUpGradientBackground() { hasGradient = true view.backgroundColor = .clear - let gradient = Gradients.defaultLokiBackground + let gradient = Gradients.defaultBackground view.setGradient(gradient) } diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 2fcc524bd..0faf2dea6 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -23,7 +23,7 @@ final class ConversationCell : UITableViewCell { result.font = .systemFont(ofSize: Values.smallFontSize) result.textColor = Colors.text result.lineBreakMode = .byTruncatingTail - result.alpha = Values.conversationCellTimestampOpacity + result.alpha = Values.lowOpacity return result }() @@ -40,11 +40,14 @@ final class ConversationCell : UITableViewCell { private lazy var statusIndicatorView: UIImageView = { let result = UIImageView() result.contentMode = .scaleAspectFit - result.layer.cornerRadius = Values.conversationCellStatusIndicatorSize / 2 + result.layer.cornerRadius = ConversationCell.conversationCellStatusIndicatorSize / 2 result.layer.masksToBounds = true return result }() + // MARK: Settings + private static let conversationCellStatusIndicatorSize: CGFloat = 14 + // MARK: Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -107,8 +110,8 @@ final class ConversationCell : UITableViewCell { bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - Values.mediumSpacing - profilePictureViewSize - Values.mediumSpacing - Values.mediumSpacing) bottomLabelStackView.set(.height, to: 18) bottomLabelSpacer.set(.height, to: 18) - statusIndicatorView.set(.width, to: Values.conversationCellStatusIndicatorSize) - statusIndicatorView.set(.height, to: Values.conversationCellStatusIndicatorSize) + statusIndicatorView.set(.width, to: ConversationCell.conversationCellStatusIndicatorSize) + statusIndicatorView.set(.height, to: ConversationCell.conversationCellStatusIndicatorSize) snippetLabel.pin(to: snippetLabelContainer) typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true diff --git a/Session/Shared/LoadingViewController.swift b/Session/Shared/LoadingViewController.swift index 926840d22..c961caad4 100644 --- a/Session/Shared/LoadingViewController.swift +++ b/Session/Shared/LoadingViewController.swift @@ -19,7 +19,7 @@ public class LoadingViewController: UIViewController { // Loki: Set gradient background view.backgroundColor = .clear - let gradient = Gradients.defaultLokiBackground + let gradient = Gradients.defaultBackground view.setGradient(gradient) self.logoView = UIImageView(image: #imageLiteral(resourceName: "SessionGreen64")) diff --git a/Session/Shared/UserCell.swift b/Session/Shared/UserCell.swift index b9ebce538..fecfa6ca0 100644 --- a/Session/Shared/UserCell.swift +++ b/Session/Shared/UserCell.swift @@ -89,7 +89,7 @@ final class UserCell : UITableViewCell { case .none: accessoryImageView.isHidden = true case .lock: accessoryImageView.isHidden = false - accessoryImageView.image = #imageLiteral(resourceName: "ic_lock_outline").asTintedImage(color: Colors.text.withAlphaComponent(Values.unimportantElementOpacity))! + accessoryImageView.image = #imageLiteral(resourceName: "ic_lock_outline").asTintedImage(color: Colors.text.withAlphaComponent(Values.mediumOpacity))! case .tick(let isSelected): accessoryImageView.isHidden = false let icon = isSelected ? #imageLiteral(resourceName: "CircleCheck") : #imageLiteral(resourceName: "Circle") diff --git a/Session/Sheets & Modals/KeyPairMigrationSuccessSheet.swift b/Session/Sheets & Modals/KeyPairMigrationSuccessSheet.swift index 842eef20d..3c863a67c 100644 --- a/Session/Sheets & Modals/KeyPairMigrationSuccessSheet.swift +++ b/Session/Sheets & Modals/KeyPairMigrationSuccessSheet.swift @@ -50,8 +50,8 @@ final class KeyPairMigrationSuccessSheet : Sheet { let sessionIDContainer = UIView() sessionIDContainer.addSubview(sessionIDLabel) sessionIDLabel.pin(to: sessionIDContainer, withInset: Values.mediumSpacing) - sessionIDContainer.layer.cornerRadius = Values.textFieldCornerRadius - sessionIDContainer.layer.borderWidth = Values.borderThickness + sessionIDContainer.layer.cornerRadius = TextField.cornerRadius + sessionIDContainer.layer.borderWidth = 1 sessionIDContainer.layer.borderColor = Colors.text.cgColor // OK button let okButton = Button(style: .prominentOutline, size: .large) diff --git a/Session/Sheets & Modals/Modal.swift b/Session/Sheets & Modals/Modal.swift index 80e0e6317..88593b1f9 100644 --- a/Session/Sheets & Modals/Modal.swift +++ b/Session/Sheets & Modals/Modal.swift @@ -10,7 +10,7 @@ class Modal : BaseVC { result.layer.cornerRadius = Values.modalCornerRadius result.layer.masksToBounds = false result.layer.borderColor = isLightMode ? UIColor.white.cgColor : Colors.modalBorder.cgColor - result.layer.borderWidth = Values.borderThickness + result.layer.borderWidth = 1 result.layer.shadowColor = UIColor.black.cgColor result.layer.shadowRadius = isLightMode ? 2 : 8 result.layer.shadowOpacity = isLightMode ? 0.1 : 0.64 @@ -31,7 +31,7 @@ class Modal : BaseVC { // MARK: Lifecycle override func viewDidLoad() { super.viewDidLoad() - let alpha = isLightMode ? CGFloat(0.1) : Values.modalBackgroundOpacity + let alpha = isLightMode ? CGFloat(0.1) : Values.highOpacity view.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(alpha) cancelButton.addTarget(self, action: #selector(close), for: UIControl.Event.touchUpInside) let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close)) diff --git a/Session/Sheets & Modals/Sheet.swift b/Session/Sheets & Modals/Sheet.swift index f9cc8056a..9b938b962 100644 --- a/Session/Sheets & Modals/Sheet.swift +++ b/Session/Sheets & Modals/Sheet.swift @@ -13,7 +13,7 @@ class Sheet : BaseVC { result.layer.cornerRadius = 24 result.layer.masksToBounds = false result.layer.borderColor = isLightMode ? UIColor.white.cgColor : Colors.modalBorder.cgColor - result.layer.borderWidth = Values.borderThickness + result.layer.borderWidth = 1 result.layer.shadowColor = UIColor.black.cgColor result.layer.shadowRadius = isLightMode ? 2 : 8 result.layer.shadowOpacity = isLightMode ? 0.1 : 0.64 @@ -23,7 +23,7 @@ class Sheet : BaseVC { // MARK: Lifecycle override func viewDidLoad() { super.viewDidLoad() - let alpha = isLightMode ? CGFloat(0.1) : Values.modalBackgroundOpacity + let alpha = isLightMode ? CGFloat(0.1) : Values.highOpacity view.backgroundColor = UIColor(hex: 0x000000).withAlphaComponent(alpha) if type(of: self).isDismissable { let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close)) @@ -35,8 +35,8 @@ class Sheet : BaseVC { private func setUpViewHierarchy() { view.addSubview(contentView) - contentView.pin(.leading, to: .leading, of: view, withInset: -Values.borderThickness) - contentView.pin(.trailing, to: .trailing, of: view, withInset: Values.borderThickness) + contentView.pin(.leading, to: .leading, of: view, withInset: -1) + contentView.pin(.trailing, to: .trailing, of: view, withInset: 1) bottomConstraint = contentView.pin(.bottom, to: .bottom, of: view, withInset: overshoot) populateContentView() } diff --git a/Session/Utilities/AvatarViewHelper.m b/Session/Utilities/AvatarViewHelper.m index d62d16c49..178f0a05f 100644 --- a/Session/Utilities/AvatarViewHelper.m +++ b/Session/Utilities/AvatarViewHelper.m @@ -62,7 +62,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssertIsOnMainThread(); OWSAssertDebug(self.delegate); - [LKAppearanceUtilities switchToImagePickerAppearance]; + [SNAppearance switchToImagePickerAppearance]; [self.delegate.fromViewController ows_askForCameraPermissions:^(BOOL granted) { if (!granted) { @@ -85,7 +85,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssertIsOnMainThread(); OWSAssertDebug(self.delegate); - [LKAppearanceUtilities switchToImagePickerAppearance]; + [SNAppearance switchToImagePickerAppearance]; [self.delegate.fromViewController ows_askForMediaLibraryPermissions:^(BOOL granted) { if (!granted) { @@ -111,7 +111,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssertIsOnMainThread(); OWSAssertDebug(self.delegate); - [LKAppearanceUtilities switchToSessionAppearance]; + [SNAppearance switchToSessionAppearance]; [self.delegate.fromViewController dismissViewControllerAnimated:YES completion:nil]; } @@ -124,7 +124,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssertIsOnMainThread(); OWSAssertDebug(self.delegate); - [LKAppearanceUtilities switchToSessionAppearance]; + [SNAppearance switchToSessionAppearance]; UIImage *rawAvatar = [info objectForKey:UIImagePickerControllerOriginalImage]; diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 9390d6027..7cc53b98b 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -1,16 +1,16 @@ @objc(LKMentionUtilities) public final class MentionUtilities : NSObject { - + override private init() { } - + @objc public static func highlightMentions(in string: String, threadID: String) -> String { return highlightMentions(in: string, isOutgoingMessage: false, threadID: threadID, attributes: [:]).string // isOutgoingMessage and attributes are irrelevant } - + @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadID: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { let userPublicKey = getUserHexEncodedPublicKey() - let publicChat = Storage.shared.getOpenGroup(for: threadID) + let openGroup = Storage.shared.getOpenGroup(for: threadID) OWSPrimaryStorage.shared().dbReadConnection.read { transaction in MentionsManager.populateUserPublicKeyCacheIfNeeded(for: threadID, in: transaction) } @@ -27,7 +27,7 @@ public final class MentionUtilities : NSObject { if publicKey == userPublicKey { displayName = OWSProfileManager.shared().localProfileName() } else { - if let publicChat = publicChat { + if let publicChat = openGroup { displayName = UserDisplayNameUtilities.getPublicChatDisplayName(for: publicKey, in: publicChat.channel, on: publicChat.server) } else { displayName = UserDisplayNameUtilities.getPrivateChatDisplayName(for: publicKey) @@ -47,9 +47,9 @@ public final class MentionUtilities : NSObject { } let result = NSMutableAttributedString(string: string, attributes: attributes) mentions.forEach { mention in - let color = isLightMode && isOutgoingMessage ? UIColor.black : Colors.accent + let color = isOutgoingMessage ? (isLightMode ? .white : .black) : Colors.accent result.addAttribute(.foregroundColor, value: color, range: mention.range) - result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.mediumFontSize), range: mention.range) + result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), range: mention.range) } return result } diff --git a/Session/Utilities/AppearanceUtilities.swift b/Session/Utilities/SNAppearance.swift similarity index 73% rename from Session/Utilities/AppearanceUtilities.swift rename to Session/Utilities/SNAppearance.swift index 142f67711..951492399 100644 --- a/Session/Utilities/AppearanceUtilities.swift +++ b/Session/Utilities/SNAppearance.swift @@ -1,6 +1,5 @@ -@objc(LKAppearanceUtilities) -final class AppearanceUtilities : NSObject { +@objc final class SNAppearance : NSObject { @objc static func switchToSessionAppearance() { if #available(iOS 13, *) { @@ -23,4 +22,12 @@ final class AppearanceUtilities : NSObject { UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : UIColor.black ] } } + + @objc static func switchToDocumentPickerAppearance() { + if #available(iOS 13, *) { + let textColor: UIColor = isDarkMode ? .white : .black + UINavigationBar.appearance().tintColor = textColor + UINavigationBar.appearance().titleTextAttributes = [ NSAttributedString.Key.foregroundColor : textColor ] + } + } } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index dc4a80ec6..2af8d4c48 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit @objc public final class SNMessagingKitConfiguration : NSObject { diff --git a/SessionMessagingKit/Database/Storage+ClosedGroups.swift b/SessionMessagingKit/Database/Storage+ClosedGroups.swift index 8ec789b85..0e9264cdf 100644 --- a/SessionMessagingKit/Database/Storage+ClosedGroups.swift +++ b/SessionMessagingKit/Database/Storage+ClosedGroups.swift @@ -1,9 +1,6 @@ -import SessionProtocolKit extension Storage { - - // MARK: - V2 - + private static func getClosedGroupEncryptionKeyPairCollection(for groupPublicKey: String) -> String { return "SNClosedGroupEncryptionKeyPairCollection-\(groupPublicKey)" } @@ -46,7 +43,7 @@ extension Storage { public func removeClosedGroupPublicKey(_ groupPublicKey: String, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: groupPublicKey, inCollection: Storage.closedGroupPublicKeyCollection) } - + public func getClosedGroupFormationTimestamp(for groupPublicKey: String) -> UInt64? { var result: UInt64? Storage.read { transaction in @@ -58,85 +55,11 @@ extension Storage { public func setClosedGroupFormationTimestamp(to timestamp: UInt64, for groupPublicKey: String, using transaction: Any) { (transaction as! YapDatabaseReadWriteTransaction).setObject(timestamp, forKey: groupPublicKey, inCollection: Storage.closedGroupFormationTimestampCollection) } - - - - // MARK: - Ratchets - - private static func getClosedGroupRatchetCollection(_ collection: ClosedGroupRatchetCollectionType, for groupPublicKey: String) -> String { - switch collection { - case .old: return "LokiOldClosedGroupRatchetCollection.\(groupPublicKey)" - case .current: return "LokiClosedGroupRatchetCollection.\(groupPublicKey)" - } - } - - public func getClosedGroupRatchet(for groupPublicKey: String, senderPublicKey: String, from collection: ClosedGroupRatchetCollectionType = .current) -> ClosedGroupRatchet? { - let collection = Storage.getClosedGroupRatchetCollection(collection, for: groupPublicKey) - var result: ClosedGroupRatchet? - Storage.read { transaction in - result = transaction.object(forKey: senderPublicKey, inCollection: collection) as? ClosedGroupRatchet - } - return result - } - public func setClosedGroupRatchet(for groupPublicKey: String, senderPublicKey: String, ratchet: ClosedGroupRatchet, in collection: ClosedGroupRatchetCollectionType = .current, using transaction: Any) { - let collection = Storage.getClosedGroupRatchetCollection(collection, for: groupPublicKey) - (transaction as! YapDatabaseReadWriteTransaction).setObject(ratchet, forKey: senderPublicKey, inCollection: collection) - } - - public func getAllClosedGroupRatchets(for groupPublicKey: String, from collection: ClosedGroupRatchetCollectionType = .current) -> [(senderPublicKey: String, ratchet: ClosedGroupRatchet)] { - let collection = Storage.getClosedGroupRatchetCollection(collection, for: groupPublicKey) - var result: [(senderPublicKey: String, ratchet: ClosedGroupRatchet)] = [] - Storage.read { transaction in - transaction.enumerateRows(inCollection: collection) { key, object, _, _ in - guard let ratchet = object as? ClosedGroupRatchet else { return } - let senderPublicKey = key - result.append((senderPublicKey: senderPublicKey, ratchet: ratchet)) - } - } - return result - } - - public func removeAllClosedGroupRatchets(for groupPublicKey: String, from collection: ClosedGroupRatchetCollectionType = .current, using transaction: Any) { - let collection = Storage.getClosedGroupRatchetCollection(collection, for: groupPublicKey) - (transaction as! YapDatabaseReadWriteTransaction).removeAllObjects(inCollection: collection) - } - - // MARK: - Private Keys - - private static let closedGroupPrivateKeyCollection = "LokiClosedGroupPrivateKeyCollection" - - public func getClosedGroupPrivateKey(for publicKey: String) -> String? { - var result: String? - Storage.read { transaction in - result = transaction.object(forKey: publicKey, inCollection: Storage.closedGroupPrivateKeyCollection) as? String - } - return result - } - - public func setClosedGroupPrivateKey(_ privateKey: String, for publicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(privateKey, forKey: publicKey, inCollection: Storage.closedGroupPrivateKeyCollection) - } - - public func removeClosedGroupPrivateKey(for publicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).removeObject(forKey: publicKey, inCollection: Storage.closedGroupPrivateKeyCollection) - } - - - - // MARK: - Convenience - - public func getAllClosedGroupSenderKeys(for groupPublicKey: String, from collection: ClosedGroupRatchetCollectionType = .current) -> Set { - return Set(getAllClosedGroupRatchets(for: groupPublicKey, from: collection).map { senderPublicKey, ratchet in - ClosedGroupSenderKey(chainKey: Data(hex: ratchet.chainKey), keyIndex: ratchet.keyIndex, publicKey: Data(hex: senderPublicKey)) - }) - } - public func getUserClosedGroupPublicKeys() -> Set { var result: Set = [] Storage.read { transaction in result = result.union(Set(transaction.allKeys(inCollection: Storage.closedGroupPublicKeyCollection))) - result = result.union(Set(transaction.allKeys(inCollection: Storage.closedGroupPrivateKeyCollection))) } return result } diff --git a/SessionMessagingKit/Database/Storage+Contacts.swift b/SessionMessagingKit/Database/Storage+Contacts.swift index 9a41ea138..df8744fa4 100644 --- a/SessionMessagingKit/Database/Storage+Contacts.swift +++ b/SessionMessagingKit/Database/Storage+Contacts.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit extension Storage { diff --git a/SessionMessagingKit/Database/Storage+Messaging.swift b/SessionMessagingKit/Database/Storage+Messaging.swift index 041e1b0f1..858ac13aa 100644 --- a/SessionMessagingKit/Database/Storage+Messaging.swift +++ b/SessionMessagingKit/Database/Storage+Messaging.swift @@ -1,10 +1,6 @@ import PromiseKit extension Storage { - - public func getOrGenerateRegistrationID(using transaction: Any) -> UInt32 { - SSKEnvironment.shared.tsAccountManager.getOrGenerateRegistrationId(transaction as! YapDatabaseReadWriteTransaction) - } /// Returns the ID of the thread. public func getOrCreateThread(for publicKey: String, groupPublicKey: String?, openGroupID: String?, using transaction: Any) -> String? { diff --git a/SessionMessagingKit/Database/TSDatabaseView.m b/SessionMessagingKit/Database/TSDatabaseView.m index d2c8562cd..43a61e541 100644 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ b/SessionMessagingKit/Database/TSDatabaseView.m @@ -12,7 +12,6 @@ #import #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index 1e2646adc..52057d996 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit import SessionUtilitiesKit public final class ClosedGroupControlMessage : ControlMessage { diff --git a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift index 2d0dd2df7..efa4d3862 100644 --- a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit @objc(SNControlMessage) public class ControlMessage : Message { } diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m index 6be45f3a8..065042c09 100644 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m @@ -11,7 +11,6 @@ #import "TSContactThread.h" #import "TSDatabaseSecondaryIndexes.h" #import "TSGroupThread.h" -#import #import #import diff --git a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m b/SessionMessagingKit/Messages/Signal/TSInfoMessage.m index f7870c696..6d4d2116c 100644 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSInfoMessage.m @@ -4,7 +4,6 @@ #import "TSInfoMessage.h" #import "SSKEnvironment.h" -#import #import #import diff --git a/Session/Conversations/Views & Cells/TypingIndicatorInteraction.swift b/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift similarity index 100% rename from Session/Conversations/Views & Cells/TypingIndicatorInteraction.swift rename to SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift diff --git a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m index ead08f7ed..a16f299e8 100644 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m +++ b/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m @@ -16,7 +16,6 @@ #import "TSThread.h" #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift index a64477da7..9872b8f1b 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift @@ -370,18 +370,11 @@ public class OWSLinkPreview: MTLModel { let matchRange: NSRange } - class func allPreviewUrls(forMessageBodyText body: String) -> [String] { + public class func allPreviewUrls(forMessageBodyText body: String) -> [String] { return allPreviewUrlMatches(forMessageBodyText: body).map { $0.urlString } } class func allPreviewUrlMatches(forMessageBodyText body: String) -> [URLMatchResult] { - guard OWSLinkPreview.featureEnabled else { - return [] - } - guard SSKPreferences.areLinkPreviewsEnabled else { - return [] - } - let detector: NSDataDetector do { detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 2c254c0ae..20846e9d5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -1,5 +1,4 @@ import CryptoSwift -import SessionProtocolKit import SessionUtilitiesKit import Sodium diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index af955b87c..470059394 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit import SignalCoreKit extension MessageReceiver { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index e91b67212..7c963af33 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -1,5 +1,4 @@ import PromiseKit -import SessionProtocolKit extension MessageSender { public static var distributingClosedGroupEncryptionKeyPairs: [String:[ECKeyPair]] = [:] diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index ed61dbd41..6fee9e1c9 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit import SessionUtilitiesKit import Sodium diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h index 44dc4be9c..7425533fa 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h +++ b/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.h @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream; @property (nonatomic, readonly, nullable) TSAttachmentPointer *thumbnailAttachmentPointer; @property (nonatomic, readonly) BOOL thumbnailDownloadFailed; +@property (nonatomic, readonly) NSString *threadId; // This property should be set IFF we are quoting a text message // or attachment with caption. @@ -51,9 +52,6 @@ NS_ASSUME_NONNULL_BEGIN - (TSQuotedMessage *)buildQuotedMessageForSending; -// Loki -@property (nonatomic, readonly) NSString *threadId; - @end NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m index d869af4cd..13a1b39c2 100644 --- a/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m +++ b/SessionMessagingKit/Sending & Receiving/Read Tracking/OWSReadReceiptManager.m @@ -14,7 +14,6 @@ #import "TSDatabaseView.h" #import "TSIncomingMessage.h" #import "YapDatabaseConnection+OWS.h" -#import #import #import @@ -183,15 +182,13 @@ NSString *const OWSReadReceiptManagerAreReadReceiptsEnabled = @"areReadReceiptsE - (void)markAsReadLocallyBeforeSortId:(uint64_t)sortId thread:(TSThread *)thread { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [self markAsReadBeforeSortId:sortId - thread:thread - readTimestamp:[NSDate millisecondTimestamp] - wasLocal:YES - transaction:transaction]; - }]; - }); + [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + [self markAsReadBeforeSortId:sortId + thread:thread + readTimestamp:[NSDate millisecondTimestamp] + wasLocal:YES + transaction:transaction]; + }]; } - (void)messageWasReadLocally:(TSIncomingMessage *)message diff --git a/SessionMessagingKit/Storage.swift b/SessionMessagingKit/Storage.swift index a835578fe..b21953e26 100644 --- a/SessionMessagingKit/Storage.swift +++ b/SessionMessagingKit/Storage.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit import PromiseKit import Sodium @@ -21,13 +20,8 @@ public protocol SessionMessagingKitStorageProtocol { func getUserProfileKey() -> Data? func getUserProfilePictureURL() -> String? - // MARK: - Signal Protocol + // MARK: - Closed Groups - func getOrGenerateRegistrationID(using transaction: Any) -> UInt32 - - // MARK: - Shared Sender Keys - - func getClosedGroupPrivateKey(for publicKey: String) -> String? func getUserClosedGroupPublicKeys() -> Set func isClosedGroup(_ publicKey: String) -> Bool @@ -61,6 +55,7 @@ public protocol SessionMessagingKitStorageProtocol { func setOpenGroupPublicKey(for server: String, to newValue: String, using transaction: Any) // MARK: - Last Message Server ID + func getLastMessageServerID(for group: UInt64, on server: String) -> UInt64? func setLastMessageServerID(for group: UInt64, on server: String, to newValue: UInt64, using transaction: Any) func removeLastMessageServerID(for group: UInt64, on server: String, using transaction: Any) diff --git a/SessionMessagingKit/Threads/Notification+Thread.swift b/SessionMessagingKit/Threads/Notification+Thread.swift index 4254aff63..77b74d0ef 100644 --- a/SessionMessagingKit/Threads/Notification+Thread.swift +++ b/SessionMessagingKit/Threads/Notification+Thread.swift @@ -2,9 +2,11 @@ public extension Notification.Name { static let groupThreadUpdated = Notification.Name("groupThreadUpdated") + static let muteSettingUpdated = Notification.Name("muteSettingUpdated") } @objc public extension NSNotification { @objc static let groupThreadUpdated = Notification.Name.groupThreadUpdated.rawValue as NSString + @objc static let muteSettingUpdated = Notification.Name.muteSettingUpdated.rawValue as NSString } diff --git a/SessionMessagingKit/Threads/TSThread.m b/SessionMessagingKit/Threads/TSThread.m index 1a3aebe4f..aae930980 100644 --- a/SessionMessagingKit/Threads/TSThread.m +++ b/SessionMessagingKit/Threads/TSThread.m @@ -451,6 +451,10 @@ BOOL IsNoteToSelfEnabled(void) changeBlock:^(TSThread *thread) { [thread setMutedUntilDate:mutedUntilDate]; }]; + + [transaction addCompletionQueue:dispatch_get_main_queue() completionBlock:^{ + [NSNotificationCenter.defaultCenter postNotificationName:NSNotification.muteSettingUpdated object:self.uniqueId]; + }]; } @end diff --git a/SessionMessagingKit/Utilities/DotNetAPI.swift b/SessionMessagingKit/Utilities/DotNetAPI.swift index 93a2d8370..2ef017a8e 100644 --- a/SessionMessagingKit/Utilities/DotNetAPI.swift +++ b/SessionMessagingKit/Utilities/DotNetAPI.swift @@ -1,7 +1,6 @@ import AFNetworking import CryptoSwift import PromiseKit -import SessionProtocolKit import SessionSnodeKit import SessionUtilitiesKit import SignalCoreKit diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h index 5a9ccc2e9..4e2c64854 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.h +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.h @@ -7,6 +7,8 @@ NS_ASSUME_NONNULL_BEGIN +@class OWSAudioPlayer; + typedef NS_ENUM(NSInteger, AudioPlaybackState) { AudioPlaybackState_Stopped, AudioPlaybackState_Playing, @@ -19,6 +21,7 @@ typedef NS_ENUM(NSInteger, AudioPlaybackState) { - (void)setAudioPlaybackState:(AudioPlaybackState)state; - (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration; - (void)showInvalidAudioFileAlert; +- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag; @end @@ -35,19 +38,14 @@ typedef NS_ENUM(NSUInteger, OWSAudioBehavior) { @interface OWSAudioPlayer : NSObject @property (nonatomic, readonly, weak) id delegate; - -// This property can be used to associate instances of the player with view -// or model objects. +// This property can be used to associate instances of the player with view or model objects. @property (nonatomic, weak) id owner; - @property (nonatomic) BOOL isLooping; +@property (nonatomic) BOOL isPlaying; +@property (nonatomic) float playbackRate; - (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior; - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl - audioBehavior:(OWSAudioBehavior)audioBehavior - delegate:(id)delegate; - +- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior delegate:(id)delegate; - (void)play; - (void)setCurrentTime:(NSTimeInterval)currentTime; - (void)pause; diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.m b/SessionMessagingKit/Utilities/OWSAudioPlayer.m index c22e1cd97..d7b93e7c9 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.m @@ -31,6 +31,11 @@ NS_ASSUME_NONNULL_BEGIN // Do nothing } +- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag +{ + // Do nothing +} + @end #pragma mark - @@ -102,9 +107,13 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Methods +- (BOOL)isPlaying +{ + return (self.delegate.audioPlaybackState == AudioPlaybackState_Playing); +} + - (void)play { - // get current audio activity [self playWithAudioActivity:self.audioActivity]; } @@ -120,6 +129,7 @@ NS_ASSUME_NONNULL_BEGIN if (!self.audioPlayer) { NSError *error; self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:self.mediaUrl error:&error]; + self.audioPlayer.enableRate = YES; if (error) { [self stop]; @@ -153,6 +163,16 @@ NS_ASSUME_NONNULL_BEGIN [self.audioPlayer setCurrentTime:currentTime]; } +- (float)getPlaybackRate +{ + return self.audioPlayer.rate; +} + +- (void)setPlaybackRate:(float)rate +{ + [self.audioPlayer setRate:rate]; +} + - (void)pause { self.delegate.audioPlaybackState = AudioPlaybackState_Paused; @@ -182,7 +202,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)togglePlayState { - if (self.delegate.audioPlaybackState == AudioPlaybackState_Playing) { + if (self.isPlaying) { [self pause]; } else { [self playWithAudioActivity:self.audioActivity]; @@ -199,6 +219,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { [self stop]; + [self.delegate audioPlayerDidFinishPlaying:self successfully:flag]; } @end diff --git a/SessionMessagingKit/Utilities/OWSBackgroundTask.m b/SessionMessagingKit/Utilities/OWSBackgroundTask.m index cd14f1b5f..3ce898431 100644 --- a/SessionMessagingKit/Utilities/OWSBackgroundTask.m +++ b/SessionMessagingKit/Utilities/OWSBackgroundTask.m @@ -4,7 +4,6 @@ #import "OWSBackgroundTask.h" #import "AppContext.h" -#import #import #import diff --git a/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m b/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m index 3cd9f7518..bd58d4abc 100644 --- a/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m +++ b/SessionMessagingKit/Utilities/OWSDisappearingMessagesFinder.m @@ -12,7 +12,6 @@ #import #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Utilities/OWSIdentityManager.m b/SessionMessagingKit/Utilities/OWSIdentityManager.m index fb7cc7586..802fb0ac4 100644 --- a/SessionMessagingKit/Utilities/OWSIdentityManager.m +++ b/SessionMessagingKit/Utilities/OWSIdentityManager.m @@ -17,7 +17,6 @@ #import "TSErrorMessage.h" #import "TSGroupThread.h" #import "TSMessage.h" -#import #import "YapDatabaseConnection+OWS.h" #import "YapDatabaseTransaction+OWS.h" #import diff --git a/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m b/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m index 3b9d32274..f51d944a8 100644 --- a/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m +++ b/SessionMessagingKit/Utilities/OWSIncomingMessageFinder.m @@ -7,7 +7,6 @@ #import "TSIncomingMessage.h" #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m b/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m index 34face9e7..26670ab6a 100644 --- a/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m +++ b/SessionMessagingKit/Utilities/OWSMediaGalleryFinder.m @@ -11,7 +11,6 @@ #import #import #import -#import #import "TSAttachment.h" NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.m b/SessionMessagingKit/Utilities/SSKEnvironment.m index 662a12556..a638d3047 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.m +++ b/SessionMessagingKit/Utilities/SSKEnvironment.m @@ -5,7 +5,6 @@ #import "SSKEnvironment.h" #import "AppContext.h" #import "OWSPrimaryStorage.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m b/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m index 77e95a89c..3f30cf2a4 100644 --- a/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m +++ b/SessionMessagingKit/Utilities/YapDatabaseConnection+OWS.m @@ -5,7 +5,6 @@ #import "YapDatabaseConnection+OWS.h" #import #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m index 3697adb5b..c95a67a1a 100644 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m +++ b/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m @@ -4,7 +4,6 @@ #import "YapDatabaseTransaction+OWS.h" #import -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SessionProtocolKit/ClosedGroupRatchet.swift b/SessionProtocolKit/ClosedGroupRatchet.swift deleted file mode 100644 index 0764da3ab..000000000 --- a/SessionProtocolKit/ClosedGroupRatchet.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SessionUtilitiesKit - -public final class ClosedGroupRatchet : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let chainKey: String - public let keyIndex: UInt - public let messageKeys: [String] - - // MARK: Initialization - public init(chainKey: String, keyIndex: UInt, messageKeys: [String]) { - self.chainKey = chainKey - self.keyIndex = keyIndex - self.messageKeys = messageKeys - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let chainKey = coder.decodeObject(forKey: "chainKey") as? String, - let keyIndex = coder.decodeObject(forKey: "keyIndex") as? UInt, - let messageKeys = coder.decodeObject(forKey: "messageKeys") as? [String] else { return nil } - self.chainKey = chainKey - self.keyIndex = UInt(keyIndex) - self.messageKeys = messageKeys - super.init() - } - - public func encode(with coder: NSCoder) { - coder.encode(chainKey, forKey: "chainKey") - coder.encode(keyIndex, forKey: "keyIndex") - coder.encode(messageKeys, forKey: "messageKeys") - } - - // MARK: Equality - override public func isEqual(_ other: Any?) -> Bool { - guard let other = other as? ClosedGroupRatchet else { return false } - return chainKey == other.chainKey && keyIndex == other.keyIndex && messageKeys == other.messageKeys - } - - // MARK: Hashing - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) - return chainKey.hashValue ^ keyIndex.hashValue ^ messageKeys.hashValue - } - - // MARK: Description - override public var description: String { "[ chainKey : \(chainKey), keyIndex : \(keyIndex), messageKeys : \(messageKeys.prettifiedDescription) ]" } -} diff --git a/SessionProtocolKit/ClosedGroupSenderKey.swift b/SessionProtocolKit/ClosedGroupSenderKey.swift deleted file mode 100644 index 332ba87d2..000000000 --- a/SessionProtocolKit/ClosedGroupSenderKey.swift +++ /dev/null @@ -1,47 +0,0 @@ -import CryptoSwift - -public final class ClosedGroupSenderKey : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let chainKey: Data - public let keyIndex: UInt - public let publicKey: Data - - // MARK: Initialization - public init(chainKey: Data, keyIndex: UInt, publicKey: Data) { - self.chainKey = chainKey - self.keyIndex = keyIndex - self.publicKey = publicKey - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let chainKey = coder.decodeObject(forKey: "chainKey") as? Data, - let keyIndex = coder.decodeObject(forKey: "keyIndex") as? UInt, - let publicKey = coder.decodeObject(forKey: "publicKey") as? Data else { return nil } - self.chainKey = chainKey - self.keyIndex = UInt(keyIndex) - self.publicKey = publicKey - super.init() - } - - public func encode(with coder: NSCoder) { - coder.encode(chainKey, forKey: "chainKey") - coder.encode(keyIndex, forKey: "keyIndex") - coder.encode(publicKey, forKey: "publicKey") - } - - // MARK: Equality - override public func isEqual(_ other: Any?) -> Bool { - guard let other = other as? ClosedGroupSenderKey else { return false } - return chainKey == other.chainKey && keyIndex == other.keyIndex && publicKey == other.publicKey - } - - // MARK: Hashing - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) - return chainKey.hashValue ^ keyIndex.hashValue ^ publicKey.hashValue - } - - // MARK: Description - override public var description: String { - return "[ chainKey : \(chainKey), keyIndex : \(keyIndex), publicKey: \(publicKey.toHexString()) ]" - } -} diff --git a/SessionProtocolKit/Configuration.swift b/SessionProtocolKit/Configuration.swift deleted file mode 100644 index 0cd23d5ed..000000000 --- a/SessionProtocolKit/Configuration.swift +++ /dev/null @@ -1,14 +0,0 @@ - -public struct SNProtocolKitConfiguration { - public let storage: SessionProtocolKitStorageProtocol - public let sharedSenderKeysDelegate: SharedSenderKeysDelegate - - internal static var shared: SNProtocolKitConfiguration! -} - -public enum SNProtocolKit { // Just to make the external API nice - - public static func configure(storage: SessionProtocolKitStorageProtocol, sharedSenderKeysDelegate: SharedSenderKeysDelegate) { - SNProtocolKitConfiguration.shared = SNProtocolKitConfiguration(storage: storage, sharedSenderKeysDelegate: sharedSenderKeysDelegate) - } -} diff --git a/SessionProtocolKit/Meta/Info.plist b/SessionProtocolKit/Meta/Info.plist deleted file mode 100644 index 9bcb24442..000000000 --- a/SessionProtocolKit/Meta/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - - diff --git a/SessionProtocolKit/Meta/SessionProtocolKit.h b/SessionProtocolKit/Meta/SessionProtocolKit.h deleted file mode 100644 index c1fcd3157..000000000 --- a/SessionProtocolKit/Meta/SessionProtocolKit.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -FOUNDATION_EXPORT double SessionProtocolKitVersionNumber; -FOUNDATION_EXPORT const unsigned char SessionProtocolKitVersionString[]; diff --git a/SessionProtocolKit/SharedSenderKeys.swift b/SessionProtocolKit/SharedSenderKeys.swift deleted file mode 100644 index c22d34ba7..000000000 --- a/SessionProtocolKit/SharedSenderKeys.swift +++ /dev/null @@ -1,164 +0,0 @@ -import CryptoSwift -import PromiseKit -import SessionUtilitiesKit - -public protocol SharedSenderKeysDelegate { - - func requestSenderKey(for groupPublicKey: String, senderPublicKey: String, using transaction: Any) -} - -public enum SharedSenderKeys { - private static let gcmTagSize: UInt = 16 - private static let ivSize: UInt = 12 - - // MARK: Ratcheting Error - public enum RatchetingError : LocalizedError { - case loadingFailed(groupPublicKey: String, senderPublicKey: String) - case messageKeyMissing(targetKeyIndex: UInt, groupPublicKey: String, senderPublicKey: String) - case generic - - public var errorDescription: String? { - switch self { - case .loadingFailed(let groupPublicKey, let senderPublicKey): return "Couldn't get ratchet for closed group with public key: \(groupPublicKey), sender public key: \(senderPublicKey)." - case .messageKeyMissing(let targetKeyIndex, let groupPublicKey, let senderPublicKey): return "Couldn't find message key for old key index: \(targetKeyIndex), public key: \(groupPublicKey), sender public key: \(senderPublicKey)." - case .generic: return "An error occurred" - } - } - } - - // MARK: Private/Internal API - public static func generateRatchet(for groupPublicKey: String, senderPublicKey: String, using transaction: Any) -> ClosedGroupRatchet { - let rootChainKey = Data.getSecureRandomData(ofSize: 32)!.toHexString() - let ratchet = ClosedGroupRatchet(chainKey: rootChainKey, keyIndex: 0, messageKeys: []) - SNProtocolKitConfiguration.shared.storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, ratchet: ratchet, in: .current, using: transaction) - return ratchet - } - - private static func step(_ ratchet: ClosedGroupRatchet) throws -> ClosedGroupRatchet { - let nextMessageKey = try HMAC(key: Data(hex: ratchet.chainKey).bytes, variant: .sha256).authenticate([ UInt8(1) ]) - let nextChainKey = try HMAC(key: Data(hex: ratchet.chainKey).bytes, variant: .sha256).authenticate([ UInt8(2) ]) - let nextKeyIndex = ratchet.keyIndex + 1 - let messageKeys = ratchet.messageKeys + [ nextMessageKey.toHexString() ] - return ClosedGroupRatchet(chainKey: nextChainKey.toHexString(), keyIndex: nextKeyIndex, messageKeys: messageKeys) - } - - /// - Note: Sync. Don't call from the main thread. - private static func stepRatchetOnce(for groupPublicKey: String, senderPublicKey: String, using transaction: Any) throws -> ClosedGroupRatchet { - #if DEBUG - assert(!Thread.isMainThread) - #endif - guard let ratchet = SNProtocolKitConfiguration.shared.storage.getClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, from: .current) else { - let error = RatchetingError.loadingFailed(groupPublicKey: groupPublicKey, senderPublicKey: senderPublicKey) - SNLog("\(error.errorDescription!)") - throw error - } - do { - let result = try step(ratchet) - SNProtocolKitConfiguration.shared.storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, ratchet: result, in: .current, using: transaction) - return result - } catch { - SNLog("Couldn't step ratchet due to error: \(error).") - throw error - } - } - - /// - Note: Sync. Don't call from the main thread. - private static func stepRatchet(for groupPublicKey: String, senderPublicKey: String, until targetKeyIndex: UInt, using transaction: Any, isRetry: Bool = false) throws -> ClosedGroupRatchet { - #if DEBUG - assert(!Thread.isMainThread) - #endif - let collection: ClosedGroupRatchetCollectionType = (isRetry) ? .old : .current - guard let ratchet = SNProtocolKitConfiguration.shared.storage.getClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, from: collection) else { - let error = RatchetingError.loadingFailed(groupPublicKey: groupPublicKey, senderPublicKey: senderPublicKey) - SNLog("\(error.errorDescription!)") - throw error - } - if targetKeyIndex < ratchet.keyIndex { - // There's no need to advance the ratchet if this is invoked for an old key index - guard ratchet.messageKeys.count > targetKeyIndex else { - let error = RatchetingError.messageKeyMissing(targetKeyIndex: targetKeyIndex, groupPublicKey: groupPublicKey, senderPublicKey: senderPublicKey) - SNLog("\(error.errorDescription!)") - throw error - } - return ratchet - } else { - var currentKeyIndex = ratchet.keyIndex - var result = ratchet - while currentKeyIndex < targetKeyIndex { - do { - result = try step(result) - currentKeyIndex = result.keyIndex - } catch { - SNLog("Couldn't step ratchet due to error: \(error).") - throw error - } - } - let collection: ClosedGroupRatchetCollectionType = (isRetry) ? .old : .current - SNProtocolKitConfiguration.shared.storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, ratchet: result, in: collection, using: transaction) - return result - } - } - - // MARK: Public API - public static func encrypt(_ plaintext: Data, for groupPublicKey: String, senderPublicKey: String, using transaction: Any) throws -> (ivAndCiphertext: Data, keyIndex: UInt) { - let ratchet: ClosedGroupRatchet - do { - ratchet = try stepRatchetOnce(for: groupPublicKey, senderPublicKey: senderPublicKey, using: transaction) - } catch { - if case RatchetingError.loadingFailed(_, _) = error { - SNProtocolKitConfiguration.shared.sharedSenderKeysDelegate.requestSenderKey(for: groupPublicKey, senderPublicKey: senderPublicKey, using: transaction) - } - throw error - } - let iv = Data.getSecureRandomData(ofSize: ivSize)! - let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagSize), mode: .combined) - let messageKey = ratchet.messageKeys.last! - let aes = try AES(key: Data(hex: messageKey).bytes, blockMode: gcm, padding: .noPadding) - let ciphertext = try aes.encrypt(plaintext.bytes) - return (ivAndCiphertext: iv + Data(ciphertext), ratchet.keyIndex) - } - - public static func decrypt(_ ivAndCiphertext: Data, for groupPublicKey: String, senderPublicKey: String, keyIndex: UInt, using transaction: Any, isRetry: Bool = false) throws -> Data { - let ratchet: ClosedGroupRatchet - do { - ratchet = try stepRatchet(for: groupPublicKey, senderPublicKey: senderPublicKey, until: keyIndex, using: transaction, isRetry: isRetry) - } catch { - if !isRetry { - return try decrypt(ivAndCiphertext, for: groupPublicKey, senderPublicKey: senderPublicKey, keyIndex: keyIndex, using: transaction, isRetry: true) - } else { - if case RatchetingError.loadingFailed(_, _) = error { - SNProtocolKitConfiguration.shared.sharedSenderKeysDelegate.requestSenderKey(for: groupPublicKey, senderPublicKey: senderPublicKey, using: transaction) - } - throw error - } - } - let iv = ivAndCiphertext[0.. 16 { // Pick an arbitrary number of message keys to try; this helps resolve issues caused by messages arriving out of order - lastNMessageKeys = [String](messageKeys[messageKeys.index(messageKeys.endIndex, offsetBy: -16).. Void) - - func getUserKeyPair() -> ECKeyPair? - func getClosedGroupRatchet(for groupPublicKey: String, senderPublicKey: String, from collection: ClosedGroupRatchetCollectionType) -> ClosedGroupRatchet? - func setClosedGroupRatchet(for groupPublicKey: String, senderPublicKey: String, ratchet: ClosedGroupRatchet, in collection: ClosedGroupRatchetCollectionType, using transaction: Any) -} diff --git a/SessionUIKit/Components/Button.swift b/SessionUIKit/Components/Button.swift index a486c9fc8..93aaaf6f6 100644 --- a/SessionUIKit/Components/Button.swift +++ b/SessionUIKit/Components/Button.swift @@ -68,7 +68,7 @@ public final class Button : UIButton { layer.cornerRadius = height / 2 backgroundColor = fillColor layer.borderColor = borderColor.cgColor - layer.borderWidth = Values.borderThickness + layer.borderWidth = 1 let fontSize = (size == .small) ? Values.smallFontSize : Values.mediumFontSize titleLabel!.font = .boldSystemFont(ofSize: fontSize) setTitleColor(textColor, for: UIControl.State.normal) diff --git a/SessionUIKit/Components/Separator.swift b/SessionUIKit/Components/Separator.swift index af5cea655..602cca355 100644 --- a/SessionUIKit/Components/Separator.swift +++ b/SessionUIKit/Components/Separator.swift @@ -6,7 +6,7 @@ public final class Separator : UIView { // MARK: Components private lazy var titleLabel: UILabel = { let result = UILabel() - result.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) result.font = .systemFont(ofSize: Values.smallFontSize) result.textAlignment = .center return result diff --git a/SessionUIKit/Components/TabBar.swift b/SessionUIKit/Components/TabBar.swift index 489ba145f..68e4a0ba0 100644 --- a/SessionUIKit/Components/TabBar.swift +++ b/SessionUIKit/Components/TabBar.swift @@ -9,7 +9,7 @@ public final class TabBar : UIView { private lazy var tabLabels: [UILabel] = tabs.map { tab in let result = UILabel() result.font = .boldSystemFont(ofSize: Values.mediumFontSize) - result.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) result.textAlignment = .center result.text = tab.title result.set(.height, to: Values.tabBarHeight - Values.separatorThickness - Values.accentLineThickness) @@ -93,7 +93,7 @@ public final class TabBar : UIView { tabLabelsCopy.remove(at: index) UIView.animate(withDuration: isAnimated ? 0.25 : 0) { tabLabel.textColor = Colors.text - tabLabelsCopy.forEach { $0.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) } + tabLabelsCopy.forEach { $0.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) } self.layoutIfNeeded() } } diff --git a/SessionUIKit/Components/TextField.swift b/SessionUIKit/Components/TextField.swift index 95e4bb20a..42dfb9b39 100644 --- a/SessionUIKit/Components/TextField.swift +++ b/SessionUIKit/Components/TextField.swift @@ -6,9 +6,12 @@ public final class TextField : UITextField { private let horizontalInset: CGFloat private let verticalInset: CGFloat + static let height: CGFloat = isIPhone5OrSmaller ? CGFloat(48) : CGFloat(80) + public static let cornerRadius: CGFloat = 8 + public init(placeholder: String, usesDefaultHeight: Bool = true, customHeight: CGFloat? = nil, customHorizontalInset: CGFloat? = nil, customVerticalInset: CGFloat? = nil) { self.usesDefaultHeight = usesDefaultHeight - self.height = customHeight ?? Values.textFieldHeight + self.height = customHeight ?? TextField.height self.horizontalInset = customHorizontalInset ?? (isIPhone5OrSmaller ? Values.mediumSpacing : Values.largeSpacing) self.verticalInset = customVerticalInset ?? (isIPhone5OrSmaller ? Values.smallSpacing : Values.largeSpacing) super.init(frame: CGRect.zero) @@ -28,7 +31,7 @@ public final class TextField : UITextField { textColor = Colors.text font = .systemFont(ofSize: Values.smallFontSize) let placeholder = NSMutableAttributedString(string: self.placeholder!) - let placeholderColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + let placeholderColor = Colors.text.withAlphaComponent(Values.mediumOpacity) placeholder.addAttribute(.foregroundColor, value: placeholderColor, range: NSRange(location: 0, length: placeholder.length)) attributedPlaceholder = placeholder tintColor = Colors.accent @@ -36,9 +39,9 @@ public final class TextField : UITextField { if usesDefaultHeight { set(.height, to: height) } - layer.borderColor = isLightMode ? Colors.text.cgColor : Colors.border.withAlphaComponent(Values.textFieldBorderOpacity).cgColor - layer.borderWidth = Values.borderThickness - layer.cornerRadius = Values.textFieldCornerRadius + layer.borderColor = isLightMode ? Colors.text.cgColor : Colors.border.withAlphaComponent(Values.lowOpacity).cgColor + layer.borderWidth = 1 + layer.cornerRadius = TextField.cornerRadius } public override func textRect(forBounds bounds: CGRect) -> CGRect { diff --git a/SessionUIKit/Components/TextView.swift b/SessionUIKit/Components/TextView.swift index bb35f1f76..45e063931 100644 --- a/SessionUIKit/Components/TextView.swift +++ b/SessionUIKit/Components/TextView.swift @@ -12,13 +12,13 @@ public final class TextView : UITextView, UITextViewDelegate { private lazy var placeholderLabel: UILabel = { let result = UILabel() result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) + result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) return result }() public init(placeholder: String, usesDefaultHeight: Bool = true, customHeight: CGFloat? = nil, customHorizontalInset: CGFloat? = nil, customVerticalInset: CGFloat? = nil) { self.usesDefaultHeight = usesDefaultHeight - self.height = customHeight ?? Values.textFieldHeight + self.height = customHeight ?? TextField.height self.horizontalInset = customHorizontalInset ?? (isIPhone5OrSmaller ? Values.mediumSpacing : Values.largeSpacing) self.verticalInset = customVerticalInset ?? (isIPhone5OrSmaller ? Values.smallSpacing : Values.largeSpacing) self.placeholder = placeholder @@ -47,9 +47,9 @@ public final class TextView : UITextView, UITextViewDelegate { if usesDefaultHeight { set(.height, to: height) } - layer.borderColor = isLightMode ? Colors.text.cgColor : Colors.border.withAlphaComponent(Values.textFieldBorderOpacity).cgColor - layer.borderWidth = Values.borderThickness - layer.cornerRadius = Values.textFieldCornerRadius + layer.borderColor = isLightMode ? Colors.text.cgColor : Colors.border.withAlphaComponent(Values.lowOpacity).cgColor + layer.borderWidth = 1 + layer.cornerRadius = TextField.cornerRadius let horizontalInset = usesDefaultHeight ? self.horizontalInset : Values.mediumSpacing textContainerInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) addSubview(placeholderLabel) diff --git a/SessionUIKit/Style Guide/Colors.swift b/SessionUIKit/Style Guide/Colors.swift index c7876635a..4d25e7cc5 100644 --- a/SessionUIKit/Style Guide/Colors.swift +++ b/SessionUIKit/Style Guide/Colors.swift @@ -23,7 +23,7 @@ public final class Colors : NSObject { @objc public static var navigationBarBackground: UIColor { UIColor(named: "session_navigation_bar_background")! } @objc public static var searchBarPlaceholder: UIColor { UIColor(named: "session_search_bar_placeholder")! } // Also used for the icons @objc public static var searchBarBackground: UIColor { UIColor(named: "session_search_bar_background")! } - @objc public static var newConversationButtonShadow: UIColor { UIColor(named: "session_new_conversation_button_shadow")! } + @objc public static var expandedButtonGlowColor: UIColor { UIColor(named: "session_expanded_button_glow_color")! } @objc public static var separator: UIColor { UIColor(named: "session_separator")! } @objc public static var unimportantButtonBackground: UIColor { UIColor(named: "session_unimportant_button_background")! } @objc public static var buttonBackground: UIColor { UIColor(named: "session_button_background")! } diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_new_conversation_button_shadow.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_expanded_button_glow_color.colorset/Contents.json similarity index 100% rename from SessionUIKit/Style Guide/Colors.xcassets/session_new_conversation_button_shadow.colorset/Contents.json rename to SessionUIKit/Style Guide/Colors.xcassets/session_expanded_button_glow_color.colorset/Contents.json diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_received_message_background.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_received_message_background.colorset/Contents.json index 07b1767dd..5c89f71ca 100644 --- a/SessionUIKit/Style Guide/Colors.xcassets/session_received_message_background.colorset/Contents.json +++ b/SessionUIKit/Style Guide/Colors.xcassets/session_received_message_background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x25", - "green" : "0x23", - "red" : "0x22" + "blue" : "0x2D", + "green" : "0x2D", + "red" : "0x2D" } }, "idiom" : "universal" diff --git a/SessionUIKit/Style Guide/Colors.xcassets/session_sent_message_background.colorset/Contents.json b/SessionUIKit/Style Guide/Colors.xcassets/session_sent_message_background.colorset/Contents.json index 3ecf00826..97b7d83d2 100644 --- a/SessionUIKit/Style Guide/Colors.xcassets/session_sent_message_background.colorset/Contents.json +++ b/SessionUIKit/Style Guide/Colors.xcassets/session_sent_message_background.colorset/Contents.json @@ -5,8 +5,8 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x7B", - "green" : "0xE9", + "blue" : "0x76", + "green" : "0xE0", "red" : "0x00" } }, @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x46", - "green" : "0x41", - "red" : "0x3F" + "blue" : "0x7B", + "green" : "0xE9", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/SessionUIKit/Style Guide/Gradients.swift b/SessionUIKit/Style Guide/Gradients.swift index 65730c225..42994a427 100644 --- a/SessionUIKit/Style Guide/Gradients.swift +++ b/SessionUIKit/Style Guide/Gradients.swift @@ -31,7 +31,7 @@ public final class Gradient : NSObject { @objc(LKGradients) final public class Gradients : NSObject { - @objc public static var defaultLokiBackground: Gradient { + @objc public static var defaultBackground: Gradient { switch AppModeManager.shared.currentAppMode { case .light: return Gradient(start: UIColor(hex: 0xFCFCFC), end: UIColor(hex: 0xFFFFFF)) case .dark: return Gradient(start: UIColor(hex: 0x171717), end: UIColor(hex: 0x121212)) diff --git a/SessionUIKit/Style Guide/Values.swift b/SessionUIKit/Style Guide/Values.swift index fd680f4d2..7b8c490c5 100644 --- a/SessionUIKit/Style Guide/Values.swift +++ b/SessionUIKit/Style Guide/Values.swift @@ -4,12 +4,10 @@ import UIKit public final class Values : NSObject { // MARK: - Alpha Values - @objc public static let unimportantElementOpacity = CGFloat(0.6) - @objc public static let conversationCellTimestampOpacity = CGFloat(0.4) - @objc public static let textFieldBorderOpacity = CGFloat(0.4) - @objc public static let modalBackgroundOpacity = CGFloat(0.75) - @objc public static let composeViewTextFieldBorderOpacity = CGFloat(0.12) - @objc public static let composeViewTextFieldPlaceholderOpacity = CGFloat(0.4) + @objc public static let veryLowOpacity = CGFloat(0.12) + @objc public static let lowOpacity = CGFloat(0.4) + @objc public static let mediumOpacity = CGFloat(0.6) + @objc public static let highOpacity = CGFloat(0.75) // MARK: - Font Sizes @objc public static let verySmallFontSize = isIPhone5OrSmaller ? CGFloat(10) : CGFloat(12) @@ -23,18 +21,18 @@ public final class Values : NSObject { @objc public static let smallButtonHeight = isIPhone5OrSmaller ? CGFloat(24) : CGFloat(27) @objc public static let mediumButtonHeight = isIPhone5OrSmaller ? CGFloat(30) : CGFloat(34) @objc public static let largeButtonHeight = isIPhone5OrSmaller ? CGFloat(40) : CGFloat(45) + @objc public static let accentLineThickness = CGFloat(4) + @objc public static let verySmallProfilePictureSize = CGFloat(26) - @objc public static let smallProfilePictureSize = CGFloat(35) + @objc public static let smallProfilePictureSize = CGFloat(33) @objc public static let mediumProfilePictureSize = CGFloat(45) @objc public static let largeProfilePictureSize = CGFloat(75) - @objc public static let borderThickness = CGFloat(1) - @objc public static let conversationCellStatusIndicatorSize = CGFloat(14) + @objc public static let searchBarHeight = CGFloat(36) - @objc public static let newConversationButtonCollapsedSize = CGFloat(60) - @objc public static let newConversationButtonExpandedSize = CGFloat(72) - @objc public static let textFieldHeight = isIPhone5OrSmaller ? CGFloat(48) : CGFloat(80) - @objc public static let textFieldCornerRadius = CGFloat(8) + + // TODO ---------------- + @objc public static let separatorLabelHeight = CGFloat(24) @objc public static var separatorThickness: CGFloat { return 1 / UIScreen.main.scale } @objc public static let tabBarHeight = isIPhone5OrSmaller ? CGFloat(32) : CGFloat(48) @@ -46,7 +44,7 @@ public final class Values : NSObject { @objc public static let fakeChatBubbleCornerRadius = CGFloat(10) @objc public static let fakeChatViewHeight = isIPhone5OrSmaller ? CGFloat(234) : CGFloat(260) @objc public static let composeViewTextFieldBorderThickness = 1 / UIScreen.main.scale - @objc public static let messageBubbleCornerRadius: CGFloat = 10 + @objc public static let messageBubbleCornerRadius: CGFloat = 8 @objc public static let progressBarThickness: CGFloat = 2 @objc public static let pnOptionCornerRadius = CGFloat(8) @objc public static let pathStatusViewSize = CGFloat(8) diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index d70410bdb..08c97811c 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -1,3 +1,5 @@ /// Returns `f(x!)` if `x != nil`, or `nil` otherwise. public func given(_ x: T?, _ f: (T) throws -> U) rethrows -> U? { return try x.map(f) } + +public func with(_ x: T, _ f: (T) throws -> U) rethrows -> U { return try f(x) } diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index e2a51e84e..ccc37cc05 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -7,6 +7,7 @@ public enum SNUserDefaults { case hasSeenGIFMetadataWarning case hasSyncedConfiguration case hasViewedSeed + case hasSeenLinkPreviewSuggestion case isUsingFullAPNs case isMigratingToV2KeyPair case isUsingMultiDevice diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index a498baeb8..0ae9bde47 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -5,19 +5,10 @@ extension OWSPrimaryStorage : OWSPrimaryStorageProtocol { } @objc(SNConfiguration) public final class Configuration : NSObject { - private static let sharedSenderKeysDelegate = SharedSenderKeysImpl() - - private final class SharedSenderKeysImpl : SharedSenderKeysDelegate { - - func requestSenderKey(for groupPublicKey: String, senderPublicKey: String, using transaction: Any) { - // Do nothing - } - } @objc public static func performMainSetup() { SNMessagingKit.configure(storage: Storage.shared) SNSnodeKit.configure(storage: Storage.shared) - SNProtocolKit.configure(storage: Storage.shared, sharedSenderKeysDelegate: sharedSenderKeysDelegate) SNUtilitiesKit.configure(owsPrimaryStorage: OWSPrimaryStorage.shared(), maxFileSize: UInt(Double(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier)) } } diff --git a/SignalUtilitiesKit/Database/Migrations/ClosedGroupsV2Migration.swift b/SignalUtilitiesKit/Database/Migrations/ClosedGroupsV2Migration.swift deleted file mode 100644 index 95e47eb17..000000000 --- a/SignalUtilitiesKit/Database/Migrations/ClosedGroupsV2Migration.swift +++ /dev/null @@ -1,38 +0,0 @@ - -@objc(SNClosedGroupsV2Migration) -public class ClosedGroupsV2Migration : OWSDatabaseMigration { - - @objc - class func migrationId() -> String { - return "006" - } - - override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) { - self.doMigrationAsync(completion: completion) - } - - private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - let publicKeys = Storage.shared.getUserClosedGroupPublicKeys() - var keyPairs: [ECKeyPair] = [] - for publicKey in publicKeys { - guard let privateKey = Storage.shared.getClosedGroupPrivateKey(for: publicKey) else { continue } - do { - let keyPair = try ECKeyPair(publicKeyData: Data(hex: publicKey.removing05PrefixIfNeeded()), privateKeyData: Data(hex: privateKey)) - keyPairs.append(keyPair) - } catch { - // Do nothing - } - } - Storage.write(with: { transaction in - for publicKey in publicKeys { - Storage.shared.addClosedGroupPublicKey(publicKey, using: transaction) - } - for keyPair in keyPairs { - Storage.shared.addClosedGroupEncryptionKeyPair(keyPair, for: keyPair.hexEncodedPublicKey, using: transaction) // In this particular case keyPair.publicKey == groupPublicKey - } - self.save(with: transaction) // Intentionally capture self - }, completion: { - completion() - }) - } -} diff --git a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m b/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m index 94b8afd4d..fd8ea2d2c 100644 --- a/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m +++ b/SignalUtilitiesKit/Database/Migrations/OWSDatabaseMigrationRunner.m @@ -26,8 +26,7 @@ NS_ASSUME_NONNULL_BEGIN - (NSArray *)allMigrations { return @[ - [SNContactsMigration new], - [SNClosedGroupsV2Migration new] + [SNContactsMigration new] ]; } diff --git a/SignalUtilitiesKit/Database/Storage+Conformances.swift b/SignalUtilitiesKit/Database/Storage+Conformances.swift index 2834f277d..21c74ee43 100644 --- a/SignalUtilitiesKit/Database/Storage+Conformances.swift +++ b/SignalUtilitiesKit/Database/Storage+Conformances.swift @@ -1,5 +1,5 @@ -extension Storage : SessionMessagingKitStorageProtocol, SessionProtocolKitStorageProtocol, SessionSnodeKitStorageProtocol { +extension Storage : SessionMessagingKitStorageProtocol, SessionSnodeKitStorageProtocol { public func updateMessageIDCollectionByPruningMessagesWithIDs(_ messageIDs: Set, using transaction: Any) { let transaction = transaction as! YapDatabaseReadWriteTransaction diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 9f088e1f8..7c13aae4e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -415,6 +415,10 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { OWSAlerts.showErrorAlert(message: NSLocalizedString("INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE", comment: "Message for the alert indicating that an audio file is invalid.")) } + public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) { + // Do nothing + } + private func ensureButtonState() { if playbackState == .playing { setAudioIconToPause() diff --git a/SignalUtilitiesKit/Messaging/ConversationStyle.swift b/SignalUtilitiesKit/Messaging/ConversationStyle.swift index f64786c2b..d64113a20 100644 --- a/SignalUtilitiesKit/Messaging/ConversationStyle.swift +++ b/SignalUtilitiesKit/Messaging/ConversationStyle.swift @@ -90,18 +90,13 @@ public class ConversationStyle: NSObject { @objc public func updateProperties() { - if thread.isGroupThread() { - gutterLeading = 12 + Values.smallProfilePictureSize + 12 - gutterTrailing = 16 - } else { - gutterLeading = 16 - gutterTrailing = 16 - } - fullWidthGutterLeading = 16 - fullWidthGutterTrailing = 16 - headerGutterLeading = 16 - headerGutterTrailing = 16 - errorGutterTrailing = 16 + gutterLeading = thread.isGroupThread() ? (12 + Values.smallProfilePictureSize + 12) : Values.mediumSpacing + gutterTrailing = Values.mediumSpacing + fullWidthGutterLeading = Values.mediumSpacing + fullWidthGutterTrailing = Values.mediumSpacing + headerGutterLeading = Values.mediumSpacing + headerGutterTrailing = Values.mediumSpacing + errorGutterTrailing = Values.mediumSpacing if thread is TSGroupThread { maxMessageWidth = floor(contentWidth) @@ -206,7 +201,7 @@ public class ConversationStyle: NSObject { @objc public func bubbleSecondaryTextColor(isIncoming: Bool) -> UIColor { - return bubbleTextColor(isIncoming: isIncoming).withAlphaComponent(Values.unimportantElementOpacity) + return bubbleTextColor(isIncoming: isIncoming).withAlphaComponent(Values.mediumOpacity) } @objc diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index ee7742dd9..7ae6e9ec6 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -1,4 +1,3 @@ -import SessionProtocolKit import PromiseKit extension MessageSender { diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch b/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch index 13ba93736..4ea96ba51 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch @@ -12,7 +12,6 @@ @import SignalCoreKit; @import SessionMessagingKit; - @import SessionProtocolKit; @import SessionSnodeKit; @import SessionUtilitiesKit; #endif diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 670ac0efe..a9ac0f9a7 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -4,7 +4,6 @@ FOUNDATION_EXPORT double SignalUtilitiesKitVersionNumber; FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; @import SessionMessagingKit; -@import SessionProtocolKit; @import SessionSnodeKit; @import SessionUtilitiesKit; diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index bb4d30f96..66dcb95dd 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -139,7 +139,7 @@ public final class ProfilePictureView : UIView { result.layer.masksToBounds = true result.backgroundColor = Colors.unimportant result.layer.borderColor = Colors.text.withAlphaComponent(0.35).cgColor - result.layer.borderWidth = Values.borderThickness + result.layer.borderWidth = 1 result.contentMode = .scaleAspectFit return result } diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m b/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m index d212f6125..0882eca30 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSTableViewController.m @@ -722,7 +722,6 @@ NSString *const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier"; } if (section.customHeaderHeight) { - OWSAssertDebug([section.customHeaderHeight floatValue] > 0); return [section.customHeaderHeight floatValue]; } else if (section.headerTitle.length > 0) { return UITableViewAutomaticDimension; diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 60f40a92d..c2cf1ffa7 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -7,7 +7,6 @@ #import "VersionMigrations.h" #import #import -#import #import #import #import diff --git a/SignalUtilitiesKit/Utilities/AppVersion.m b/SignalUtilitiesKit/Utilities/AppVersion.m index a671e59b6..efea79e1d 100755 --- a/SignalUtilitiesKit/Utilities/AppVersion.m +++ b/SignalUtilitiesKit/Utilities/AppVersion.m @@ -4,7 +4,6 @@ #import "AppVersion.h" #import "NSUserDefaults+OWS.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/ByteParser.m b/SignalUtilitiesKit/Utilities/ByteParser.m index f70baa02a..f8f1f6b10 100644 --- a/SignalUtilitiesKit/Utilities/ByteParser.m +++ b/SignalUtilitiesKit/Utilities/ByteParser.m @@ -3,7 +3,6 @@ // #import "ByteParser.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/FunctionalUtil.m b/SignalUtilitiesKit/Utilities/FunctionalUtil.m index 5ff42f203..830b47812 100644 --- a/SignalUtilitiesKit/Utilities/FunctionalUtil.m +++ b/SignalUtilitiesKit/Utilities/FunctionalUtil.m @@ -3,7 +3,6 @@ // #import "FunctionalUtil.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/NSArray+OWS.m b/SignalUtilitiesKit/Utilities/NSArray+OWS.m index 191e915c4..cb6c06376 100644 --- a/SignalUtilitiesKit/Utilities/NSArray+OWS.m +++ b/SignalUtilitiesKit/Utilities/NSArray+OWS.m @@ -4,7 +4,6 @@ #import "NSArray+OWS.h" #import "TSYapDatabaseObject.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/OWSError.m b/SignalUtilitiesKit/Utilities/OWSError.m index b5cfc1435..b089f235d 100644 --- a/SignalUtilitiesKit/Utilities/OWSError.m +++ b/SignalUtilitiesKit/Utilities/OWSError.m @@ -3,7 +3,6 @@ // #import "OWSError.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/OWSOperation.m b/SignalUtilitiesKit/Utilities/OWSOperation.m index 3b7df2079..5c496949a 100644 --- a/SignalUtilitiesKit/Utilities/OWSOperation.m +++ b/SignalUtilitiesKit/Utilities/OWSOperation.m @@ -5,7 +5,6 @@ #import "OWSOperation.h" #import "OWSBackgroundTask.h" #import "OWSError.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/SignalAccount.m b/SignalUtilitiesKit/Utilities/SignalAccount.m index 36fad15a5..28c9ce906 100644 --- a/SignalUtilitiesKit/Utilities/SignalAccount.m +++ b/SignalUtilitiesKit/Utilities/SignalAccount.m @@ -7,7 +7,6 @@ #import "NSString+SSK.h" #import "OWSPrimaryStorage.h" #import "SignalRecipient.h" -#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift b/SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift index d9a3a42b6..582f9d38f 100644 --- a/SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift +++ b/SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift @@ -9,7 +9,7 @@ public final class ViewControllerUtilities : NSObject { public static func setUpDefaultSessionStyle(for vc: UIViewController, title: String?, hasCustomBackButton: Bool) { // Set gradient background vc.view.backgroundColor = .clear - let gradient = Gradients.defaultLokiBackground + let gradient = Gradients.defaultBackground vc.view.setGradient(gradient) // Set navigation bar background color if let navigationBar = vc.navigationController?.navigationBar {