From 097bb9411849fbd947ce3522b40d3df772873128 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 30 Oct 2025 11:33:29 +1100 Subject: [PATCH 1/3] [WIP] Refactoring message composition box UI hierarchy --- Session.xcodeproj/project.pbxproj | 467 +++++++++++--- .../xcshareddata/xcschemes/Session.xcscheme | 11 + ...ssionNotificationServiceExtension.xcscheme | 11 + .../xcschemes/SessionShareExtension.xcscheme | 11 + .../Session_CompileLibSession.xcscheme | 13 + .../SessionCallManager+Action.swift | 7 - .../Call Management/SessionCallManager.swift | 3 - Session/Calls/CallVC.swift | 6 - .../Views & Modals/IncomingCallBanner.swift | 6 - .../ConversationVC+Interaction.swift | 475 +++++--------- Session/Conversations/ConversationVC.swift | 584 +++++++----------- .../Conversations/ConversationViewModel.swift | 67 +- .../Emoji Picker/EmojiPickerSheet.swift | 8 +- .../ExpandingAttachmentsButton.swift | 190 ------ .../Content Views/LinkPreviewState.swift | 87 +-- .../Content Views/LinkPreviewView.swift | 249 -------- .../SwiftUI/LinkPreviewView_SwiftUI.swift | 40 +- .../SwiftUI/QuoteView_SwiftUI.swift | 244 -------- .../Message Cells/VisibleMessageCell.swift | 111 ++-- ...ift => AfterLayoutCallbackTableView.swift} | 45 +- .../Views/MessageRequestsCell.swift | 6 +- .../AllMediaViewController.swift | 6 +- .../MediaGalleryViewModel.swift | 12 +- .../MediaPageViewController.swift | 9 +- .../MediaTileViewController.swift | 6 +- .../MessageInfoScreen.swift | 30 +- .../SendMediaNavigationController.swift | 38 +- .../MediaDismissAnimationController.swift | 178 ++---- .../MediaPresentationContext.swift | 286 ++++++++- .../MediaZoomAnimationController.swift | 206 +++--- Session/Meta/AppDelegate.swift | 10 - Session/Meta/Session+SNUIKit.swift | 1 + .../Views/ThemeMessagePreviewView.swift | 2 +- .../Shared/Types/SessionCell+Accessory.swift | 18 +- .../Views/SessionCell+AccessoryView.swift | 2 +- Session/Utilities/BackgroundPoller.swift | 1 + .../Utilities/ImageLoading+Convenience.swift | 65 +- .../MentionUtilities+DisplayName.swift | 24 +- Session/Utilities/MockDataGenerator.swift | 2 +- Session/Utilities/Permissions.swift | 42 +- .../Database/Models/Attachment.swift | 16 +- .../Database/Models/GroupMember.swift | 4 +- .../Database/Models/Profile.swift | 19 +- .../Open Groups/OpenGroupManager.swift | 74 ++- .../Pollers/CommunityPoller.swift | 1 + .../Pollers/PollerType.swift | 1 + .../Quotes/QuotedReplyModel.swift | 3 + .../Shared Models/MentionInfo.swift | 160 ----- .../Shared Models/MessageInputTypes.swift | 9 - .../Shared Models/MessageViewModel.swift | 305 +++++---- .../SessionThreadViewModel.swift | 46 +- ...ionSelectionView+SessionMessagingKit.swift | 205 ++++++ .../ProfilePictureView+Convenience.swift | 36 +- .../Utilities/SessionProState.swift | 2 - .../ShareNavController.swift | 1 + SessionShareExtension/ThreadPickerVC.swift | 1 + .../ThreadPickerViewModel.swift | 1 + SessionTests/Session.xctestplan | 20 +- .../Components}/Input View/InputView.swift | 532 +++++++++------- .../Input View/InputViewButton.swift | 18 +- .../Input View/MentionSelectionView.swift | 112 ++-- .../VoiceMessageRecordingView.swift | 33 +- SessionUIKit/Components/LinkPreviewView.swift | 233 +++++++ .../Modals & Toast/ConfirmationModal.swift | 4 +- .../Components/ProfilePictureView.swift | 150 ++--- .../Components}/QuoteView.swift | 161 ++--- .../Components/SessionImageView.swift | 34 +- .../Components/SwiftUI/ProCTAModal.swift | 4 - .../SwiftUI/QuoteView_SwiftUI.swift | 438 +++++++++++++ .../Components/SwiftUI/UserProfileModal.swift | 4 +- SessionUIKit/Configuration.swift | 47 +- .../Types}/LinkPreviewDraft.swift | 3 +- .../Types}/ReusableView.swift | 0 .../Types}/TimeUnit.swift | 0 SessionUIKit/Utilities/String+Utilities.swift | 101 +++ .../Utilities}/TimeInterval+Utilities.swift | 0 .../UICollectionView+ReusableView.swift | 0 .../Utilities/UIEdgeInsets+Utilities.swift | 6 +- .../Utilities}/UITableView+ReusableView.swift | 0 .../Utilities/UTType+Localization.swift | 15 + SessionUIKitTests/SessionUIKit.xctestplan | 25 + .../Utilities/SUIKStringUtilitiesSpec.swift | 2 +- SessionUtilitiesKit/Database/Storage.swift | 12 + .../Types/PagedDatabaseObserver.swift | 20 +- .../General/CallRingTonePlayer.swift | 2 +- SessionUtilitiesKit/General/Logging.swift | 8 +- .../General/String+Utilities.swift | 101 --- SessionUtilitiesKit/JobRunner/JobRunner.swift | 4 +- ...AttachmentApprovalInputAccessoryView.swift | 2 + .../AttachmentApprovalViewController.swift | 52 +- .../AttachmentTextToolbar.swift | 14 +- .../Utilities/UIViewController+OWS.swift | 4 +- 92 files changed, 3563 insertions(+), 3061 deletions(-) delete mode 100644 Session/Conversations/Input View/ExpandingAttachmentsButton.swift delete mode 100644 Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift delete mode 100644 Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift rename Session/Conversations/Views & Modals/{InsetLockableTableView.swift => AfterLayoutCallbackTableView.swift} (56%) delete mode 100644 SessionMessagingKit/Shared Models/MentionInfo.swift delete mode 100644 SessionMessagingKit/Shared Models/MessageInputTypes.swift create mode 100644 SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift rename {Session/Conversations => SessionUIKit/Components}/Input View/InputView.swift (57%) rename {Session/Conversations => SessionUIKit/Components}/Input View/MentionSelectionView.swift (66%) rename {Session/Conversations => SessionUIKit/Components}/Input View/VoiceMessageRecordingView.swift (94%) create mode 100644 SessionUIKit/Components/LinkPreviewView.swift rename {Session/Conversations/Message Cells/Content Views => SessionUIKit/Components}/QuoteView.swift (50%) create mode 100644 SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift rename {SessionMessagingKit/Sending & Receiving/Link Previews => SessionUIKit/Types}/LinkPreviewDraft.swift (92%) rename {SessionUtilitiesKit/General => SessionUIKit/Types}/ReusableView.swift (100%) rename {SessionUtilitiesKit/Utilities => SessionUIKit/Types}/TimeUnit.swift (100%) rename {SessionUtilitiesKit/General => SessionUIKit/Utilities}/TimeInterval+Utilities.swift (100%) rename {SessionUtilitiesKit/General => SessionUIKit/Utilities}/UICollectionView+ReusableView.swift (100%) rename SessionUtilitiesKit/General/UIEdgeInsets.swift => SessionUIKit/Utilities/UIEdgeInsets+Utilities.swift (54%) rename {SessionUtilitiesKit/General => SessionUIKit/Utilities}/UITableView+ReusableView.swift (100%) create mode 100644 SessionUIKit/Utilities/UTType+Localization.swift create mode 100644 SessionUIKitTests/SessionUIKit.xctestplan rename SessionUtilitiesKitTests/Utilities/StringUtilitiesSpec.swift => SessionUIKitTests/Utilities/SUIKStringUtilitiesSpec.swift (97%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index dcc9e4b793..42a710749e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -195,7 +195,6 @@ 94AAB1582E24BD3700A6FA18 /* PinnedConversationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */; }; 94AAB15E2E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; 94AAB1602E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; - 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProState.swift */; }; 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */; }; 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */; }; @@ -210,7 +209,6 @@ 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962B2E1B85920097754D /* InputViewButton.swift */; }; 94CD962E2E1B85920097754D /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962A2E1B85920097754D /* InputTextView.swift */; }; 94CD96302E1B88430097754D /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */; }; - 94CD96322E1B88C20097754D /* ExpandingAttachmentsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */; }; 94CD96402E1BABE90097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; @@ -230,12 +228,10 @@ B817AD9A26436593009DF825 /* SimplifiedConversationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B817AD9926436593009DF825 /* SimplifiedConversationCell.swift */; }; B817AD9C26436F73009DF825 /* ThreadPickerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B817AD9B26436F73009DF825 /* ThreadPickerVC.swift */; }; B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149C025D605C6009C0F2A /* InfoBanner.swift */; }; - B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.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 */; }; - 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 */; }; B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8569AC225CB5D2900DBA3DB /* ConversationVC+Interaction.swift */; }; @@ -284,10 +280,8 @@ C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5D22554B05A00555489 /* TypingIndicator.swift */; }; C300A5F22554B09800555489 /* MessageSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5F12554B09800555489 /* MessageSender.swift */; }; C300A60D2554B31900555489 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5CE2553860700C340D1 /* Logging.swift */; }; - C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; C32824D325C9F9790062D0A7 /* SessionNetworkingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionNetworkingKit.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 */; }; @@ -319,10 +313,8 @@ C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; }; C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */; }; C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C354E75923FE2A7600CE22E3 /* BaseVC.swift */; }; - C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */; }; C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35E8AAD2485E51D00ACB629 /* IP2Country.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 */; }; C37F5414255BAFA7002AEA92 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; C37F54DC255BB84A002AEA92 /* SessionNetworkingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionNetworkingKit.framework */; }; @@ -567,7 +559,6 @@ FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; - FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5BC2554B00D00555489 /* ReadReceipt.swift */; }; FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF306255B6DBE007E1867 /* OWSWindowManager.m */; }; @@ -657,7 +648,6 @@ FD42ECCE2E287CD4002D03EA /* ThemeColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD42ECCD2E287CD1002D03EA /* ThemeColor.swift */; }; FD42ECD02E289261002D03EA /* ThemeLinearGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD42ECCF2E289257002D03EA /* ThemeLinearGradient.swift */; }; FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD42ECD12E3071DC002D03EA /* ThemeText.swift */; }; - FD42ECD42E32FF2E002D03EA /* StringUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD42ECD32E32FF2A002D03EA /* StringUtilitiesSpec.swift */; }; FD42ECD62E3308B5002D03EA /* ObservableKey+SessionUtilitiesKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD42ECD52E3308AC002D03EA /* ObservableKey+SessionUtilitiesKit.swift */; }; FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432433299C6985008A0213 /* PendingReadReceipt.swift */; }; FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */; }; @@ -677,7 +667,7 @@ FD49E2472B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD49E2482B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD49E2492B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; - FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; + FD4B200E283492210034334B /* AfterLayoutCallbackTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* AfterLayoutCallbackTableView.swift */; }; FD4BB22B2D63F20700D0DC3D /* MigrationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */; }; FD4C53AF2CC1D62E003B10F4 /* _035_ReworkRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C53AE2CC1D61E003B10F4 /* _035_ReworkRecipientState.swift */; }; FD52090328B4680F006098F6 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090228B4680F006098F6 /* RadioButton.swift */; }; @@ -801,7 +791,6 @@ FD6DA9D22D0160F10092085A /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD6DA9D12D0160F10092085A /* Lucide */; }; FD6F5B5E2E657A24009A8D01 /* CancellationAwareAsyncStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6F5B5D2E657A24009A8D01 /* CancellationAwareAsyncStream.swift */; }; FD6F5B602E657A33009A8D01 /* StreamLifecycleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6F5B5F2E657A32009A8D01 /* StreamLifecycleManager.swift */; }; - FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD70F25C2DC1F184003729B7 /* _040_MessageDeduplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD70F25B2DC1F176003729B7 /* _040_MessageDeduplicationTable.swift */; }; FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; }; FD7115F228C6CB3900B47552 /* _019_AddThreadIdToFTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F128C6CB3900B47552 /* _019_AddThreadIdToFTS.swift */; }; @@ -815,7 +804,6 @@ FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71161428D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift */; }; FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71161628D00DA400B47552 /* ThreadSettingsViewModelSpec.swift */; }; FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71161928D00E1100B47552 /* NotificationContentViewModelSpec.swift */; }; - FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71161B28D194FB00B47552 /* MentionInfo.swift */; }; FD71161E28D9772700B47552 /* UIViewController+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71161D28D9772700B47552 /* UIViewController+OWS.swift */; }; FD71162028D97ABC00B47552 /* UIImage+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */; }; FD71162228D983ED00B47552 /* QRCodeScanningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71162128D983ED00B47552 /* QRCodeScanningViewController.swift */; }; @@ -857,7 +845,6 @@ FD756BF22D06687800BD7199 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BF12D06687800BD7199 /* Lucide */; }; FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; - FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */; }; FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */; }; FD78E9F22DDA9EA200D55B50 /* MockImageDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */; }; @@ -880,7 +867,6 @@ FD83B9C727CF3F10005E1583 /* CapabilitySpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C627CF3F10005E1583 /* CapabilitySpec.swift */; }; FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; - FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */; }; FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageViewModel.swift */; }; @@ -939,11 +925,32 @@ FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9E26AF2EA5DC7D00404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */; }; + FD9E26B22EA72BE600404C7F /* QuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C328251E25CA3A900062D0A7 /* QuoteView.swift */; }; + FD9E26B32EA72CC500404C7F /* UIEdgeInsets+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets+Utilities.swift */; }; + FD9E26C72EA72DAC00404C7F /* SUIKStringUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26C62EA72D7D00404C7F /* SUIKStringUtilitiesSpec.swift */; }; + FD9E26C92EA72DC200404C7F /* SessionUIKit.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = FD9E26C82EA72DC200404C7F /* SessionUIKit.xctestplan */; }; + FD9E26CB2EA72E2600404C7F /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = FD9E26CA2EA72E2600404C7F /* Quick */; }; + FD9E26CD2EA72E2600404C7F /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD9E26CC2EA72E2600404C7F /* Nimble */; }; + FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; + FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26CF2EA73F4800404C7F /* UTType+Localization.swift */; }; FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* SessionImageView.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */; }; FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */; }; + FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; + FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; }; + FDAA36AB2EB2C45E0040603E /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */; }; + FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; + FDAA36AD2EB2C61D0040603E /* TimeUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */; }; + FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; + FDAA36AF2EB2C6EE0040603E /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; + FDAA36B22EB2D2F60040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B12EB2D2F60040603E /* NVActivityIndicatorView */; }; + FDAA36B42EB2DFA30040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B32EB2DFA30040603E /* NVActivityIndicatorView */; }; + FDAA36B72EB2E55C0040603E /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; + FDAB8A812EB2A45D000A6C65 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; + FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; + FDAB8A852EB2BC37000A6C65 /* MentionSelectionView+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */; }; FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */; }; @@ -988,12 +995,10 @@ FDB5DB142A981FAE002C8721 /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; FDB5DB152A981FB0002C8721 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FDB6A87C2AD75B7F002D4F96 /* PhotosUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDB6A87B2AD75B7F002D4F96 /* PhotosUI.framework */; }; - FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; FDB7400D28EBEC240094D718 /* DateHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */; }; FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBA8A832D59796F007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift */; }; FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */; }; FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBEE52D2B6A18B900C143A0 /* UserDefaultsConfig.swift */; }; - FDC0F0042BFECE12002CBFB9 /* TimeUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */; }; FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC1BD652CFD6C4E002CDC71 /* Config.swift */; }; FDC1BD682CFE6EEB002CDC71 /* DeveloperSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */; }; FDC1BD6A2CFE7B6B002CDC71 /* DirectoryArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC1BD692CFE7B67002CDC71 /* DirectoryArchiver.swift */; }; @@ -1080,7 +1085,6 @@ FDE754CC2C9BAF37002A2623 /* MediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C72C9BAF36002A2623 /* MediaUtils.swift */; }; FDE754CD2C9BAF37002A2623 /* UTType+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */; }; FDE754D22C9BAF53002A2623 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D12C9BAF53002A2623 /* JobDependencies.swift */; }; - FDE754D42C9BAF6B002A2623 /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */; }; FDE754DB2C9BAF8A002A2623 /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D52C9BAF89002A2623 /* Crypto.swift */; }; FDE754DC2C9BAF8A002A2623 /* CryptoError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D62C9BAF89002A2623 /* CryptoError.swift */; }; FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D72C9BAF89002A2623 /* Mnemonic.swift */; }; @@ -1330,6 +1334,13 @@ remoteGlobalIDString = C331FF1A2558F9D300070591; remoteInfo = SessionUIKit; }; + FD9E26BC2EA72D3E00404C7F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = D221A088169C9E5E00537ABF; + remoteInfo = Session; + }; FDB348812BE86A4400B716C2 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D221A080169C9E5E00537ABF /* Project object */; @@ -1606,7 +1617,6 @@ 94CD962A2E1B85920097754D /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; 94CD962B2E1B85920097754D /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; - 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; @@ -1706,7 +1716,7 @@ C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = ""; }; C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Wrapping.swift"; sourceTree = ""; }; C354E75923FE2A7600CE22E3 /* BaseVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseVC.swift; sourceTree = ""; }; - C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIEdgeInsets.swift; sourceTree = ""; }; + C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIEdgeInsets+Utilities.swift"; sourceTree = ""; }; C35E8AAD2485E51D00ACB629 /* IP2Country.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IP2Country.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 = ""; }; @@ -2019,7 +2029,6 @@ FD42ECCD2E287CD1002D03EA /* ThemeColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeColor.swift; sourceTree = ""; }; FD42ECCF2E289257002D03EA /* ThemeLinearGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeLinearGradient.swift; sourceTree = ""; }; FD42ECD12E3071DC002D03EA /* ThemeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeText.swift; sourceTree = ""; }; - FD42ECD32E32FF2A002D03EA /* StringUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringUtilitiesSpec.swift; sourceTree = ""; }; FD42ECD52E3308AC002D03EA /* ObservableKey+SessionUtilitiesKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionUtilitiesKit.swift"; sourceTree = ""; }; FD432433299C6985008A0213 /* PendingReadReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingReadReceipt.swift; sourceTree = ""; }; FD47E0AA2AA68EEA00A55E41 /* Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Authentication.swift; sourceTree = ""; }; @@ -2031,7 +2040,7 @@ FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppContext.swift; sourceTree = ""; }; FD481AA22CB889A400ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJobSpec.swift; sourceTree = ""; }; FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = ""; }; - FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; + FD4B200D283492210034334B /* AfterLayoutCallbackTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterLayoutCallbackTableView.swift; sourceTree = ""; }; FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationHelper.swift; sourceTree = ""; }; FD4C53AE2CC1D61E003B10F4 /* _035_ReworkRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _035_ReworkRecipientState.swift; sourceTree = ""; }; FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; @@ -2089,7 +2098,6 @@ FD6DF00A2ACFE40D0084BA4C /* _021_AddSnodeReveivedMessageInfoPrimaryKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _021_AddSnodeReveivedMessageInfoPrimaryKey.swift; sourceTree = ""; }; FD6F5B5D2E657A24009A8D01 /* CancellationAwareAsyncStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationAwareAsyncStream.swift; sourceTree = ""; }; FD6F5B5F2E657A32009A8D01 /* StreamLifecycleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamLifecycleManager.swift; sourceTree = ""; }; - FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD70F25B2DC1F176003729B7 /* _040_MessageDeduplicationTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _040_MessageDeduplicationTable.swift; sourceTree = ""; }; FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = ""; }; FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHeaderView.swift; sourceTree = ""; }; @@ -2105,7 +2113,6 @@ FD71161428D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDisappearingMessagesViewModelSpec.swift; sourceTree = ""; }; FD71161628D00DA400B47552 /* ThreadSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModelSpec.swift; sourceTree = ""; }; FD71161928D00E1100B47552 /* NotificationContentViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentViewModelSpec.swift; sourceTree = ""; }; - FD71161B28D194FB00B47552 /* MentionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionInfo.swift; sourceTree = ""; }; FD71161D28D9772700B47552 /* UIViewController+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+OWS.swift"; sourceTree = ""; }; FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Utilities.swift"; sourceTree = ""; }; FD71162128D983ED00B47552 /* QRCodeScanningViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScanningViewController.swift; sourceTree = ""; }; @@ -2162,7 +2169,6 @@ FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryWithDependencies.swift; sourceTree = ""; }; FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; - FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = ""; }; FD848B9728422F1A000E298B /* Date+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Utilities.swift"; sourceTree = ""; }; @@ -2209,11 +2215,16 @@ FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift; sourceTree = ""; }; + FD9E26B82EA72D3E00404C7F /* SessionUIKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUIKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FD9E26C62EA72D7D00404C7F /* SUIKStringUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUIKStringUtilitiesSpec.swift; sourceTree = ""; }; + FD9E26C82EA72DC200404C7F /* SessionUIKit.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SessionUIKit.xctestplan; sourceTree = ""; }; + FD9E26CF2EA73F4800404C7F /* UTType+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+Localization.swift"; sourceTree = ""; }; FDA335F42D911576007E0EB6 /* SessionImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionImageView.swift; sourceTree = ""; }; FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsType.swift; sourceTree = ""; }; FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Sound.swift"; sourceTree = ""; }; FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationPreviewType.swift"; sourceTree = ""; }; + FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionSelectionView+SessionMessagingKit.swift"; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupUrlInfo.swift; sourceTree = ""; }; @@ -2506,6 +2517,7 @@ files = ( FD756BF22D06687800BD7199 /* Lucide in Frameworks */, FD2286712C38D43000BC06F7 /* DifferenceKit in Frameworks */, + FDAA36B22EB2D2F60040603E /* NVActivityIndicatorView in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2581,6 +2593,7 @@ FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */, FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */, 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */, + FDAA36B42EB2DFA30040603E /* NVActivityIndicatorView in Frameworks */, B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */, 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */, 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */, @@ -2631,6 +2644,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD9E26B52EA72D3E00404C7F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FD9E26CB2EA72E2600404C7F /* Quick in Frameworks */, + FD9E26CD2EA72E2600404C7F /* Nimble in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FDC4388B27B9FFC700C60D73 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -2805,7 +2827,6 @@ 7BA37AF82AEB365C002438F8 /* DocumentView_SwiftUI.swift */, 7BAFA7592AAEA281001DA43E /* LinkPreviewView_SwiftUI.swift */, 7B5802982AAEF1B50050EEB1 /* OpenGroupInvitationView_SwiftUI.swift */, - 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */, ); path = SwiftUI; sourceTree = ""; @@ -2893,6 +2914,7 @@ FD42ECD12E3071DC002D03EA /* ThemeText.swift */, 942256922C23F8DD00C0FDBF /* Toast.swift */, FDE5219B2E08E76600061B8E /* SessionAsyncImage.swift */, + 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */, ); path = SwiftUI; sourceTree = ""; @@ -2921,8 +2943,11 @@ 94CD96282E1B855E0097754D /* Input View */ = { isa = PBXGroup; children = ( + B8269D2825C7A4B400488AB4 /* InputView.swift */, 94CD962A2E1B85920097754D /* InputTextView.swift */, 94CD962B2E1B85920097754D /* InputViewButton.swift */, + C302093D25DCBF07001F572D /* MentionSelectionView.swift */, + C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */, ); path = "Input View"; sourceTree = ""; @@ -2957,11 +2982,9 @@ 3488F9352191CC4000E524CC /* MediaView.swift */, B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */, B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */, - C328251E25CA3A900062D0A7 /* QuoteView.swift */, 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, C328250E25CA06020062D0A7 /* VoiceMessageView.swift */, B8569AE225CBB19A00DBA3DB /* DocumentView.swift */, - B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */, B8D84EA225DF745A005A043E /* LinkPreviewState.swift */, B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */, 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */, @@ -2978,7 +3001,7 @@ B897621B25D201F7004F83B2 /* RoundIconButton.swift */, B82149C025D605C6009C0F2A /* InfoBanner.swift */, C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */, - FD4B200D283492210034334B /* InsetLockableTableView.swift */, + FD4B200D283492210034334B /* AfterLayoutCallbackTableView.swift */, 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */, 7B5233C32900E90F00F8F375 /* SessionLabelCarouselView.swift */, FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */, @@ -2989,7 +3012,6 @@ B835246C25C38AA20089A44F /* Conversations */ = { isa = PBXGroup; children = ( - B887C38125C7C79700E11DAE /* Input View */, B835247725C38D190089A44F /* Message Cells */, C328252E25CA54F70062D0A7 /* Context Menu */, B821493625D4D6A7009C0F2A /* Views & Modals */, @@ -3018,17 +3040,6 @@ path = "Message Cells"; sourceTree = ""; }; - B887C38125C7C79700E11DAE /* Input View */ = { - isa = PBXGroup; - children = ( - B8269D2825C7A4B400488AB4 /* InputView.swift */, - 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */, - C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */, - C302093D25DCBF07001F572D /* MentionSelectionView.swift */, - ); - path = "Input View"; - sourceTree = ""; - }; B8A582AB258C64E800AFD84C /* Database */ = { isa = PBXGroup; children = ( @@ -3070,7 +3081,6 @@ FDE754BF2C9BAEF6002A2623 /* Array+Utilities.swift */, FD47E0AA2AA68EEA00A55E41 /* Authentication.swift */, FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, - 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */, FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, @@ -3081,15 +3091,10 @@ C33FDAFD255A580600E217F9 /* LRUCache.swift */, C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */, 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */, - FD705A91278D051200F16121 /* ReusableView.swift */, FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, FD7728952849E7E90018502F /* String+Utilities.swift */, - FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, - FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */, - FD7728972849E8110018502F /* UITableView+ReusableView.swift */, - C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */, C38EF2EF255B6DBB007E1867 /* Weak.swift */, FD6673FE2D77F9BE00041530 /* ScreenLock.swift */, @@ -3308,7 +3313,6 @@ children = ( B8B320B6258C30D70020074B /* HTMLMetadata.swift */, FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */, - C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */, ); path = "Link Previews"; sourceTree = ""; @@ -3364,11 +3368,16 @@ FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */, + FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */, FDE754BD2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift */, + FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */, FD37E9D628A20B5D003AE748 /* UIColor+Utilities.swift */, FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */, FDE754B92C9B97B8002A2623 /* UIDevice+Utilities.swift */, + C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets+Utilities.swift */, FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */, + FD7728972849E8110018502F /* UITableView+ReusableView.swift */, + FD9E26CF2EA73F4800404C7F /* UTType+Localization.swift */, B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */, C33100272559000A00070591 /* UIView+Utilities.swift */, ); @@ -3397,12 +3406,14 @@ C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */, FD6673FC2D77F54400041530 /* ScreenLockViewController.swift */, 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */, + B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */, FD0150572CA27DEE005B08A1 /* ScrollableLabel.swift */, 7B8914762A7CAAE200A4C627 /* SessionHostingViewController.swift */, FDA335F42D911576007E0EB6 /* SessionImageView.swift */, FD71165A28E6DDBC00B47552 /* StyledNavigationController.swift */, FD0B77AF29B69A65009169BA /* TopBannerController.swift */, FDB348622BE3774000B716C2 /* BezierPathView.swift */, + C328251E25CA3A900062D0A7 /* QuoteView.swift */, ); path = Components; sourceTree = ""; @@ -3684,6 +3695,7 @@ FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */, + FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */, FD1A55422E179AE6003761E4 /* ObservableKeyEvent+Utilities.swift */, @@ -3893,6 +3905,7 @@ C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */, FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */, FD71160A28D00BAE00B47552 /* SessionTests */, + FD9E26C32EA72D5600404C7F /* SessionUIKitTests */, FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, FDB5DAFB2A981C43002C8721 /* SessionNetworkingKitTests */, FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */, @@ -3917,6 +3930,7 @@ FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */, FD71160928D00BAE00B47552 /* SessionTests.xctest */, FDB5DAFA2A981C42002C8721 /* SessionNetworkingKitTests.xctest */, + FD9E26B82EA72D3E00404C7F /* SessionUIKitTests.xctest */, ); name = Products; sourceTree = ""; @@ -4007,7 +4021,6 @@ FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */, FD00CDCA2D5317A3006B96D3 /* Scheduler+Utilities.swift */, FDB11A532DCD7A7B00BEF49F /* Task+Utilities.swift */, - FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */, FDE755132C9BC169002A2623 /* UIAlertAction+Utilities.swift */, FDE755152C9BC169002A2623 /* UIApplicationState+Utilities.swift */, FDE755172C9BC169002A2623 /* UIBezierPath+Utilities.swift */, @@ -4311,8 +4324,6 @@ isa = PBXGroup; children = ( FD71162B28E1451400B47552 /* Position.swift */, - FD71161B28D194FB00B47552 /* MentionInfo.swift */, - FD848B8C283E0B26000E298B /* MessageInputTypes.swift */, FD848B86283B844B000E298B /* MessageViewModel.swift */, FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */, FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */, @@ -4633,7 +4644,10 @@ FDE6E99729F8E63A00F93C5D /* Accessibility.swift */, FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, + C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, + 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, + FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */, ); path = Types; sourceTree = ""; @@ -4926,6 +4940,23 @@ path = Utilities; sourceTree = ""; }; + FD9E26C32EA72D5600404C7F /* SessionUIKitTests */ = { + isa = PBXGroup; + children = ( + FD9E26C82EA72DC200404C7F /* SessionUIKit.xctestplan */, + FD9E26C52EA72D7500404C7F /* Utilities */, + ); + path = SessionUIKitTests; + sourceTree = ""; + }; + FD9E26C52EA72D7500404C7F /* Utilities */ = { + isa = PBXGroup; + children = ( + FD9E26C62EA72D7D00404C7F /* SUIKStringUtilitiesSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FDAA16792AC28E2200DDBF77 /* Models */ = { isa = PBXGroup; children = ( @@ -5276,7 +5307,6 @@ children = ( FD01503D2CA2433D005B08A1 /* BencodeDecoderSpec.swift */, FD01503E2CA2433D005B08A1 /* BencodeEncoderSpec.swift */, - FD42ECD32E32FF2A002D03EA /* StringUtilitiesSpec.swift */, FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */, FD01503F2CA2433D005B08A1 /* VersionSpec.swift */, ); @@ -5427,6 +5457,7 @@ packageProductDependencies = ( FD2286702C38D43000BC06F7 /* DifferenceKit */, FD756BF12D06687800BD7199 /* Lucide */, + FDAA36B12EB2D2F60040603E /* NVActivityIndicatorView */, ); productName = SessionUIKit; productReference = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; @@ -5563,6 +5594,7 @@ FD6DA9CE2D015B440092085A /* Lucide */, FD6DA9D12D0160F10092085A /* Lucide */, FD756BEF2D06686500BD7199 /* Lucide */, + FDAA36B32EB2DFA30040603E /* NVActivityIndicatorView */, ); productName = RedPhone; productReference = D221A089169C9E5E00537ABF /* Session.app */; @@ -5613,6 +5645,28 @@ productReference = FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + FD9E26B72EA72D3E00404C7F /* SessionUIKitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FD9E26BE2EA72D3E00404C7F /* Build configuration list for PBXNativeTarget "SessionUIKitTests" */; + buildPhases = ( + FD9E26B42EA72D3E00404C7F /* Sources */, + FD9E26B52EA72D3E00404C7F /* Frameworks */, + FD9E26B62EA72D3E00404C7F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + FD9E26BD2EA72D3E00404C7F /* PBXTargetDependency */, + ); + name = SessionUIKitTests; + packageProductDependencies = ( + FD9E26CA2EA72E2600404C7F /* Quick */, + FD9E26CC2EA72E2600404C7F /* Nimble */, + ); + productName = SessionUIKitTests; + productReference = FD9E26B82EA72D3E00404C7F /* SessionUIKitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; FDB5DAF92A981C42002C8721 /* SessionNetworkingKitTests */ = { isa = PBXNativeTarget; buildConfigurationList = FD2272522C32910F004D8A6C /* Build configuration list for PBXNativeTarget "SessionNetworkingKitTests" */; @@ -5661,7 +5715,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; DefaultBuildSystemTypeForWorkspace = Original; - LastSwiftUpdateCheck = 1520; + LastSwiftUpdateCheck = 1630; LastTestingUpgradeCheck = 0600; LastUpgradeCheck = 1600; ORGANIZATIONNAME = "Rangeproof Pty Ltd"; @@ -5758,6 +5812,10 @@ FD83B9AE27CF200A005E1583 = { CreatedOnToolsVersion = 13.2.1; }; + FD9E26B72EA72D3E00404C7F = { + CreatedOnToolsVersion = 16.3; + TestTargetID = D221A088169C9E5E00537ABF; + }; FDB5DAF92A981C42002C8721 = { CreatedOnToolsVersion = 14.3; }; @@ -5803,6 +5861,7 @@ C3C2A59E255385C100C340D1 /* SessionNetworkingKit */, C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */, FD71160828D00BAE00B47552 /* SessionTests */, + FD9E26B72EA72D3E00404C7F /* SessionUIKitTests */, FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */, FDB5DAF92A981C42002C8721 /* SessionNetworkingKitTests */, FD83B9AE27CF200A005E1583 /* SessionUtilitiesKitTests */, @@ -5948,6 +6007,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD9E26B62EA72D3E00404C7F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD9E26C92EA72DC200404C7F /* SessionUIKit.xctestplan in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FDB5DAF82A981C42002C8721 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -6245,6 +6312,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, + FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, @@ -6252,9 +6321,11 @@ FD71165828E436E800B47552 /* Modal.swift in Sources */, FD42ECCE2E287CD4002D03EA /* ThemeColor.swift in Sources */, FD0150582CA27DF3005B08A1 /* ScrollableLabel.swift in Sources */, + FDAA36B72EB2E55C0040603E /* InputView.swift in Sources */, FD37E9D328A1FCDB003AE748 /* Theme+OceanDark.swift in Sources */, FD8A5B102DBF2F17004C689B /* NavBarSessionIcon.swift in Sources */, 94CD96302E1B88430097754D /* CGRect+Utilities.swift in Sources */, + FD9E26B22EA72BE600404C7F /* QuoteView.swift in Sources */, 94CD95BB2E08D9E00097754D /* SessionProBadge.swift in Sources */, 942256972C23F8DD00C0FDBF /* SessionSearchBar.swift in Sources */, FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */, @@ -6288,19 +6359,24 @@ 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, FDE754BA2C9B97B8002A2623 /* UIDevice+Utilities.swift in Sources */, C331FFB92558FA8D00070591 /* UIView+Constraints.swift in Sources */, + FDAA36AB2EB2C45E0040603E /* UICollectionView+ReusableView.swift in Sources */, FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */, 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */, FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */, + FD9E26B32EA72CC500404C7F /* UIEdgeInsets+Utilities.swift in Sources */, FD8A5B0D2DBF2CA1004C689B /* Localization.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, + FDAA36AD2EB2C61D0040603E /* TimeUnit.swift in Sources */, 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */, 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, + FDAB8A812EB2A45D000A6C65 /* LinkPreviewDraft.swift in Sources */, FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, FD42ECD02E289261002D03EA /* ThemeLinearGradient.swift in Sources */, FDE754BE2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift in Sources */, @@ -6311,20 +6387,25 @@ 942256942C23F8DD00C0FDBF /* ActivityView.swift in Sources */, FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */, 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */, + FDAA36AF2EB2C6EE0040603E /* LinkPreviewView.swift in Sources */, FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */, C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, FD71162028D97ABC00B47552 /* UIImage+Utilities.swift in Sources */, 947D7FE72D51837200E8E413 /* ArrowCapsule.swift in Sources */, 947D7FE82D51837200E8E413 /* PopoverView.swift in Sources */, + FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */, FD8A5B202DC03337004C689B /* AdaptiveText.swift in Sources */, 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */, FD71162A28DA83DF00B47552 /* GradientView.swift in Sources */, 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, + FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, + FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */, + FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, FD8A5B0A2DBF246A004C689B /* Constants.swift in Sources */, C331FF9A2558FA6B00070591 /* Values.swift in Sources */, @@ -6553,7 +6634,6 @@ FDE754DF2C9BAF8A002A2623 /* KeyPair.swift in Sources */, FD2272D12C34EBD6004D8A6C /* JSONDecoder+Utilities.swift in Sources */, FD6A38F12C2A66B100762359 /* KeychainStorage.swift in Sources */, - FDC0F0042BFECE12002CBFB9 /* TimeUnit.swift in Sources */, FD2272EA2C351CA7004D8A6C /* Threading.swift in Sources */, FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */, FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */, @@ -6579,7 +6659,6 @@ FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */, FD78EA022DDEBC3200D55B50 /* DebounceTaskManager.swift in Sources */, FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */, - FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */, FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */, FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */, FD6673FF2D77F9C100041530 /* ScreenLock.swift in Sources */, @@ -6591,7 +6670,6 @@ FD09796B27F6C67500936362 /* Failable.swift in Sources */, FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */, FD7115FA28C8153400B47552 /* UIBarButtonItem+Combine.swift in Sources */, - FD705A92278D051200F16121 /* ReusableView.swift in Sources */, FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */, FD52CB652E13B6E900A4DA70 /* ObservationBuilder.swift in Sources */, FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */, @@ -6609,7 +6687,6 @@ FD848B9A28442CE6000E298B /* StorageError.swift in Sources */, FDE755222C9BC1BA002A2623 /* LibSessionError.swift in Sources */, FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */, - FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */, FD428B1B2B4B6098006D0888 /* Notifications+Lifecycle.swift in Sources */, 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, FD2272D02C34EBD0004D8A6C /* FileManager.swift in Sources */, @@ -6622,14 +6699,12 @@ FDC1BD6A2CFE7B6B002CDC71 /* DirectoryArchiver.swift in Sources */, FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, FD7728962849E7E90018502F /* String+Utilities.swift in Sources */, - C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */, FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, FD39370C2E4D7BCA00571F17 /* DocumentPickerHandler.swift in Sources */, C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, FD29598D2A43BC0B00888A17 /* Version.swift in Sources */, FD71160028C8253500B47552 /* UIView+Combine.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, - FDE754D42C9BAF6B002A2623 /* UICollectionView+ReusableView.swift in Sources */, FDE754C02C9BAEF6002A2623 /* Array+Utilities.swift in Sources */, FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */, @@ -6681,7 +6756,6 @@ FDE754A32C9A8FD1002A2623 /* SwarmPoller.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, - FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */, @@ -6695,7 +6769,6 @@ FD2272FD2C352D8E004D8A6C /* LibSession+ConvoInfoVolatile.swift in Sources */, FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */, FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */, - FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */, FD2273082C353109004D8A6C /* DisplayPictureManager.swift in Sources */, @@ -6833,7 +6906,6 @@ FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, - FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, FDFE75B12ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, FD2273012C352D8E004D8A6C /* LibSession+Shared.swift in Sources */, @@ -6859,6 +6931,7 @@ C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, + FDAB8A852EB2BC37000A6C65 /* MentionSelectionView+SessionMessagingKit.swift in Sources */, FDB5DADA2A95D839002C8721 /* GroupUpdateInfoChangeMessage.swift in Sources */, FDF71EA32B072C2800A8D6B5 /* LibSessionMessage.swift in Sources */, FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */, @@ -6923,7 +6996,6 @@ FDE754F82C9BB0B0002A2623 /* UserNotificationConfig.swift in Sources */, FED288F32E4C28CF00C31171 /* AppReviewPromptDialog.swift in Sources */, 7B0EFDF62755CC5400FFAAE7 /* CallMissedTipsModal.swift in Sources */, - C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */, 9422568A2C23F8C800C0FDBF /* LoadingScreen.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, @@ -6937,7 +7009,6 @@ 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, 7BA37AFD2AEF7C3D002438F8 /* VoiceMessageView_SwiftUI.swift in Sources */, 7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */, - B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */, FD71164428E2CB8A00B47552 /* SessionCell+Accessory.swift in Sources */, 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */, FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */, @@ -7011,8 +7082,6 @@ 7BAF54CF27ACCEEC003D12F8 /* GlobalSearchViewController.swift in Sources */, FD37EA1728AC5605003AE748 /* NotificationContentViewModel.swift in Sources */, FD3FAB612AEA194E00DC5421 /* UserListViewModel.swift in Sources */, - 94CD96322E1B88C20097754D /* ExpandingAttachmentsButton.swift in Sources */, - 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, @@ -7061,7 +7130,7 @@ B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, - FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, + FD4B200E283492210034334B /* AfterLayoutCallbackTableView.swift in Sources */, FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */, FDC1BD682CFE6EEB002CDC71 /* DeveloperSettingsViewModel.swift in Sources */, 7B0EFDF0275084AA00FFAAE7 /* CallMessageCell.swift in Sources */, @@ -7101,10 +7170,8 @@ 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */, FDE754B62C9B96BB002A2623 /* WebRTCSession+UI.swift in Sources */, - C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, FD71163828E2C50700B47552 /* SessionTableViewModel.swift in Sources */, FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */, - C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, 7B3A39322980D02B002FE4AC /* SessionCarouselView.swift in Sources */, B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, @@ -7112,7 +7179,6 @@ FED288F82E4C3BE100C31171 /* AppReviewPromptModel.swift in Sources */, FDE754B12C9B96B4002A2623 /* TurnServerInfo.swift in Sources */, 7BD687D12A5D0D1200D8E455 /* MessageInfoScreen.swift in Sources */, - B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, FD71162E28E168C700B47552 /* SettingsViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -7179,7 +7245,6 @@ FD0150412CA2433D005B08A1 /* BencodeEncoderSpec.swift in Sources */, FD0150422CA2433D005B08A1 /* VersionSpec.swift in Sources */, FD636C6A2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, - FD42ECD42E32FF2E002D03EA /* StringUtilitiesSpec.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, FD0150482CA243CB005B08A1 /* Mock.swift in Sources */, FDFE75B42ABD46B600655640 /* MockUserDefaults.swift in Sources */, @@ -7198,6 +7263,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD9E26B42EA72D3E00404C7F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD9E26C72EA72DAC00404C7F /* SUIKStringUtilitiesSpec.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FDB5DAF62A981C42002C8721 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -7417,6 +7490,11 @@ target = C331FF1A2558F9D300070591 /* SessionUIKit */; targetProxy = FD8A5B1A2DBF47E9004C689B /* PBXContainerItemProxy */; }; + FD9E26BD2EA72D3E00404C7F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D221A088169C9E5E00537ABF /* Session */; + targetProxy = FD9E26BC2EA72D3E00404C7F /* PBXContainerItemProxy */; + }; FDB348822BE86A4400B716C2 /* PBXTargetDependency */ = { isa = PBXTargetDependency; targetProxy = FDB348812BE86A4400B716C2 /* PBXContainerItemProxy */; @@ -8809,6 +8887,220 @@ }; name = App_Store_Release_Compile_LibSession; }; + FD9E26BF2EA72D3E00404C7F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.SessionUIKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; + }; + name = Debug; + }; + FD9E26C02EA72D3E00404C7F /* Debug_Compile_LibSession */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.SessionUIKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; + }; + name = Debug_Compile_LibSession; + }; + FD9E26C12EA72D3E00404C7F /* App_Store_Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.SessionUIKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; + VALIDATE_PRODUCT = YES; + }; + name = App_Store_Release; + }; + FD9E26C22EA72D3E00404C7F /* App_Store_Release_Compile_LibSession */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.getsession.SessionUIKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; + VALIDATE_PRODUCT = YES; + }; + name = App_Store_Release_Compile_LibSession; + }; FDC4389627B9FFC700C60D73 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -8986,7 +9278,6 @@ FDC605732C71DC03009B3D45 /* Debug_Compile_LibSession */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; @@ -9571,7 +9862,6 @@ FDC605802C71DC14009B3D45 /* App_Store_Release_Compile_LibSession */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ADDRESS_SANITIZER_CONTAINER_OVERFLOW = NO; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; @@ -10445,6 +10735,17 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = App_Store_Release; }; + FD9E26BE2EA72D3E00404C7F /* Build configuration list for PBXNativeTarget "SessionUIKitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD9E26BF2EA72D3E00404C7F /* Debug */, + FD9E26C02EA72D3E00404C7F /* Debug_Compile_LibSession */, + FD9E26C12EA72D3E00404C7F /* App_Store_Release */, + FD9E26C22EA72D3E00404C7F /* App_Store_Release_Compile_LibSession */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = App_Store_Release; + }; FDC4389527B9FFC700C60D73 /* Build configuration list for PBXNativeTarget "SessionMessagingKitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -10704,6 +11005,26 @@ package = FD6A38ED2C2A641200762359 /* XCRemoteSwiftPackageReference "DifferenceKit" */; productName = DifferenceKit; }; + FD9E26CA2EA72E2600404C7F /* Quick */ = { + isa = XCSwiftPackageProductDependency; + package = FD6A39302C2AD33E00762359 /* XCRemoteSwiftPackageReference "Quick" */; + productName = Quick; + }; + FD9E26CC2EA72E2600404C7F /* Nimble */ = { + isa = XCSwiftPackageProductDependency; + package = FD6A39392C2AD3A300762359 /* XCRemoteSwiftPackageReference "Nimble" */; + productName = Nimble; + }; + FDAA36B12EB2D2F60040603E /* NVActivityIndicatorView */ = { + isa = XCSwiftPackageProductDependency; + package = FD6A39202C2AA91D00762359 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; + productName = NVActivityIndicatorView; + }; + FDAA36B32EB2DFA30040603E /* NVActivityIndicatorView */ = { + isa = XCSwiftPackageProductDependency; + package = FD6A39202C2AA91D00762359 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; + productName = NVActivityIndicatorView; + }; FDEF57292C3CF50B00131302 /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = FD6A390E2C2A93CD00762359 /* XCRemoteSwiftPackageReference "WebRTC" */; diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme index e20af5e4f5..c8b46a343b 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme @@ -134,6 +134,17 @@ ReferencedContainer = "container:Session.xcodeproj"> + + + + + + + + + + + + + + + + + + String? { @@ -302,14 +287,7 @@ extension ConversationVC: sendMessage(text: (messageText ?? ""), attachments: attachments) resetMentions() - dismiss(animated: true) { [weak self] in - if self?.isFirstResponder == false { - self?.becomeFirstResponder() - } - else { - self?.reloadInputViews() - } - } + dismiss(animated: true) } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -326,7 +304,7 @@ extension ConversationVC: func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { } - // MARK: - ExpandingAttachmentsButtonDelegate + // MARK: - Attachment Buttons func handleGIFButtonTapped() { guard viewModel.dependencies.mutate(cache: .libSession, { $0.get(.isGiphyEnabled) }) else { @@ -367,8 +345,6 @@ extension ConversationVC: self.documentHandler = DocumentPickerHandler( didPickDocumentsAt: { [weak self, dependencies = viewModel.dependencies] _, urls in defer { - self?.showInputAccessoryView() - self?.becomeFirstResponder() self?.documentHandler = nil } @@ -480,8 +456,6 @@ extension ConversationVC: self?.showAttachmentApprovalDialog(for: [ pendingAttachment ]) }, wasCancelled: { [weak self] _ in - self?.showInputAccessoryView() - self?.becomeFirstResponder() self?.documentHandler = nil } ) @@ -493,12 +467,14 @@ extension ConversationVC: func handleLibraryButtonTapped() { let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let quoteDraft: QuoteViewModel? = self.snInputView.quoteDraft Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] in DispatchQueue.main.async { let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst( threadId: threadId, threadVariant: threadVariant, + quoteDraft: quoteDraft, using: dependencies ) sendMediaNavController.sendMediaNavDelegate = self @@ -520,6 +496,7 @@ extension ConversationVC: let sendMediaNavController = SendMediaNavigationController.showingCameraFirst( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, + quoteDraft: self.snInputView.quoteDraft, using: self.viewModel.dependencies ) sendMediaNavController.sendMediaNavDelegate = self @@ -539,6 +516,7 @@ extension ConversationVC: threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, attachments: attachments, + quoteDraft: snInputView.quoteDraft, approvalDelegate: self, disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), didLoadLinkPreview: nil, @@ -560,11 +538,7 @@ extension ConversationVC: @MainActor func handleCharacterLimitLabelTapped() { guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, - beforePresented: { [weak self] in - self?.hideInputAccessoryView() - }, afterClosed: { [weak self] in - self?.showInputAccessoryView() self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, presenting: { [weak self] modal in @@ -574,7 +548,6 @@ extension ConversationVC: return } - self.hideInputAccessoryView() let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), isSessionPro: viewModel.isCurrentUserSessionPro @@ -602,15 +575,36 @@ extension ConversationVC: scrollMode: .never ), cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - afterClosed: { [weak self] in - self?.showInputAccessoryView() - } + cancelStyle: .alert_text ) ) present(confirmationModal, animated: true, completion: nil) } + @MainActor func handleAttachmentButtonTapped() { + if attachmentButtonStackView.isHidden { + snInputView.attachmentsButton.accessibilityLabel = "Collapse attachment options" + attachmentButtonStackView.isHidden = false + + UIView.animate(withDuration: 0.25) { + self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = false } + self.attachmentButtonStackView.alpha = 1 + } + } else { + snInputView.attachmentsButton.accessibilityLabel = "Add attachment" + UIView.animate( + withDuration: 0.25, + animations: { + self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = true } + self.attachmentButtonStackView.alpha = 0 + }, + completion: { [weak self] _ in + self?.attachmentButtonStackView.isHidden = true + } + ) + } + } + @MainActor func handleDisabledAttachmentButtonTapped() { /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order @@ -659,18 +653,14 @@ extension ConversationVC: sendMessage( text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), linkPreviewDraft: snInputView.linkPreviewInfo?.draft, - quoteModel: snInputView.quoteDraftInfo?.model + quoteViewModel: snInputView.quoteDraft ) } @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, - beforePresented: { [weak self] in - self?.hideInputAccessoryView() - }, afterClosed: { [weak self] in - self?.showInputAccessoryView() self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, presenting: { [weak self] modal in @@ -680,7 +670,6 @@ extension ConversationVC: return } - self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modalMessageCharacterTooLongTitle".localized(), @@ -691,10 +680,7 @@ extension ConversationVC: scrollMode: .never ), cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - afterClosed: { [weak self] in - self?.showInputAccessoryView() - } + cancelStyle: .alert_text ) ) present(confirmationModal, animated: true, completion: nil) @@ -704,7 +690,7 @@ extension ConversationVC: text: String, attachments: [PendingAttachment] = [], linkPreviewDraft: LinkPreviewDraft? = nil, - quoteModel: QuotedReplyModel? = nil, + quoteViewModel: QuoteViewModel? = nil, hasPermissionToSendSeed: Bool = false ) { guard !showBlockedModalIfNeeded() else { return } @@ -721,7 +707,7 @@ extension ConversationVC: } catch { return showErrorAlert(for: error) } - let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines)) + let processedText: String = mentions.update(text.trimmingCharacters(in: .whitespacesAndNewlines)) // If we have no content then do nothing guard !processedText.isEmpty || !attachments.isEmpty else { return } @@ -740,7 +726,7 @@ extension ConversationVC: text: text, attachments: attachments, linkPreviewDraft: linkPreviewDraft, - quoteModel: quoteModel, + quoteViewModel: quoteViewModel, hasPermissionToSendSeed: true ) } @@ -752,7 +738,7 @@ extension ConversationVC: // Clearing this out immediately to make this appear more snappy snInputView.text = "" - snInputView.quoteDraftInfo = nil + snInputView.quoteDraft = nil resetMentions() scrollToBottom(isAnimated: false) @@ -769,7 +755,7 @@ extension ConversationVC: sentTimestampMs: sentTimestampMs, attachments: attachments, linkPreviewDraft: linkPreviewDraft, - quoteModel: quoteModel + quoteViewModel: quoteViewModel ) await approveMessageRequestIfNeeded( for: self.viewModel.threadData.threadId, @@ -841,11 +827,11 @@ extension ConversationVC: } // If there is a Quote the insert it now - if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = optimisticData.quoteModel { + if let interactionId: Int64 = insertedInteraction.id, let quoteViewModel: QuoteViewModel = optimisticData.quoteViewModel { try Quote( interactionId: interactionId, - authorId: quoteModel.authorId, - timestampMs: quoteModel.timestampMs + authorId: quoteViewModel.authorId, + timestampMs: quoteViewModel.timestampMs ).insert(db) } @@ -912,9 +898,6 @@ extension ConversationVC: } @MainActor func showLinkPreviewSuggestionModal() { - // Hides accessory view while link preview confirmation is presented - hideInputAccessoryView() - let linkPreviewModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "linkPreviewsEnable".localized(), @@ -930,10 +913,6 @@ extension ConversationVC: dependencies.setAsync(.areLinkPreviewsEnabled, true) { self?.snInputView.autoGenerateLinkPreview() } - }, - afterClosed: { [weak self] in - // Bring back accessory view after confirmation action - self?.showInputAccessoryView() } ) ) @@ -947,6 +926,7 @@ extension ConversationVC: guard !viewIsAppearing else { return } let newText: String = (inputTextView.text ?? "") + let currentUserSessionIds: Set = (viewModel.threadData.currentUserSessionIds ?? []) if !newText.isEmpty { Task { [threadData = viewModel.threadData, dependencies = viewModel.dependencies] in @@ -959,10 +939,13 @@ extension ConversationVC: } } - updateMentions(for: newText) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.updateMentions(for: newText, currentUserSessionIds: currentUserSessionIds) + } + // Note: When calculating the number of characters left, we need to use the original mention // text which contains the session id rather than display name. - snInputView.updateNumberOfCharactersLeft(replaceMentions(in: newText)) + snInputView.updateNumberOfCharactersLeft(mentions.update(newText)) } // MARK: --Attachments @@ -978,6 +961,7 @@ extension ConversationVC: threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, attachments: [ pendingAttachment ], + quoteDraft: self.snInputView.quoteDraft, approvalDelegate: self, disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), didLoadLinkPreview: nil, @@ -990,138 +974,86 @@ extension ConversationVC: // MARK: --Mentions - @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) { + @MainActor func handleMentionSelected(_ viewModel: MentionSelectionView.ViewModel, from view: MentionSelectionView) { guard let currentMentionStartIndex = currentMentionStartIndex else { return } - mentions.append(mentionInfo) - - let displayNameForMention: String = mentionInfo.profile.displayNameForMention( - for: self.viewModel.threadData.threadVariant, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) + mentions.append(viewModel) let newText: String = snInputView.text.replacingCharacters( in: currentMentionStartIndex..., - with: "@\(displayNameForMention) " // stringlint:ignore + with: "@\(viewModel.displayName) " // stringlint:ignore ) snInputView.text = newText self.currentMentionStartIndex = nil snInputView.hideMentionsUI() - mentions = mentions.filter { mentionInfo -> Bool in - newText.contains( - mentionInfo.profile.displayNameForMention( - for: self.viewModel.threadData.threadVariant, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) - ) - } + mentions = mentions.filter { newText.contains($0.displayName) } } - func updateMentions(for newText: String) { + func updateMentions(for newText: String, currentUserSessionIds: Set) async { + let currentStartIndex: String.Index? = await MainActor.run { currentMentionStartIndex } + guard !newText.isEmpty else { - if currentMentionStartIndex != nil { - snInputView.hideMentionsUI() + await MainActor.run { + if currentStartIndex != nil { + snInputView.hideMentionsUI() + } + + resetMentions() } - - resetMentions() return } - let lastCharacterIndex = newText.index(before: newText.endIndex) - let lastCharacter = newText[lastCharacterIndex] - - // Check if there is whitespace before the '@' or the '@' is the first character - let isCharacterBeforeLastWhiteSpaceOrStartOfLine: Bool - if newText.count == 1 { - isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line - } - else { - let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)] - isCharacterBeforeLastWhiteSpaceOrStartOfLine = characterBeforeLast.isWhitespace - } - - // stringlint:ignore_start - if lastCharacter == "@" && isCharacterBeforeLastWhiteSpaceOrStartOfLine { - currentMentionStartIndex = lastCharacterIndex - snInputView.showMentionsUI( - for: self.viewModel.mentions(), - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) - } - else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ - currentMentionStartIndex = nil - snInputView.hideMentionsUI() - } - else { - if let currentMentionStartIndex = currentMentionStartIndex { - let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ - snInputView.showMentionsUI( - for: self.viewModel.mentions(for: query), - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) - } - } - // stringlint:ignore_stop - } - - func resetMentions() { - currentMentionStartIndex = nil - mentions = [] - } - - // stringlint:ignore_contents - func replaceMentions(in text: String) -> String { - var result = text + let lastCharacterIndex: String.Index = newText.index(before: newText.endIndex) + let lastCharacter: String = String(newText[lastCharacterIndex]) - for mention in mentions { - let displayNameForMention: String = mention.profile.displayNameForMention( - for: mention.threadVariant, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) + /// Check the surrounds of the last character to ensure the user is after a mention + let lastCharacterIsMentionChar: Bool = (lastCharacter == MentionSelectionView.ViewModel.mentionChar) + let whitespaceOrNewLineBeforeMentionChar: Bool = { + guard newText.count > 1 else { return true } /// Start of line + + return newText[newText.index(before: lastCharacterIndex)].isWhitespace + }() + let doubleMentionChar: Bool = { + guard newText.count > 1 else { return false } /// Only a single char + guard currentStartIndex != nil || lastCharacterIsMentionChar else { return false } /// No mention char + + let mentionCharIndex: String.Index = ( + currentStartIndex ?? + newText.index(before: lastCharacterIndex) ) - guard let range = result.range(of: "@\(displayNameForMention)") else { continue } - result = result.replacingCharacters(in: range, with: "@\(mention.profile.id)") - } + return (String(newText[mentionCharIndex]) == MentionSelectionView.ViewModel.mentionChar) + }() + let isValidMention: Bool = ( + lastCharacterIsMentionChar && + whitespaceOrNewLineBeforeMentionChar && + !doubleMentionChar + ) - return result - } - - func hideInputAccessoryView() { - guard Thread.isMainThread else { - DispatchQueue.main.async { - self.hideInputAccessoryView() - } - return - } - self.isKeyboardVisible = self.snInputView.isInputFirstResponder - self.inputAccessoryView?.resignFirstResponder() - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 - } - - func showInputAccessoryView() { - guard Thread.isMainThread else { - DispatchQueue.main.async { - self.showInputAccessoryView() + /// If it's not a valid mention then we need to reset the state and hide the mentions UI (if visible) + guard isValidMention else { + await MainActor.run { + currentMentionStartIndex = nil + snInputView.hideMentionsUI() } return } - if !self.isFirstResponder { - // Force this object to become the First Responder. This is necessary - // to trigger the display of its associated inputAccessoryView - // and/or inputView. - self.becomeFirstResponder() - } + let query: String = (lastCharacterIsMentionChar ? + "" : + String(newText[newText.index(after: currentStartIndex ?? newText.startIndex)...]) /// + 1 to get rid of the @ + ) + let mentions: [MentionSelectionView.ViewModel] = ((try? await self.viewModel.mentions(for: query)) ?? []) - UIView.animate(withDuration: 0.25, animations: { - self.inputAccessoryView?.isHidden = false - self.inputAccessoryView?.alpha = 1 - if self.isKeyboardVisible { - self.inputAccessoryView?.becomeFirstResponder() - } - }) + await MainActor.run { + snInputView.showMentionsUI(for: mentions) + } + } + + @MainActor func resetMentions() { + currentMentionStartIndex = nil + mentions = [] } // MARK: MessageCellDelegate @@ -1154,9 +1086,6 @@ extension ConversationVC: ) else { return } - /// Lock the contentOffset of the tableView so the transition doesn't look buggy - self.tableView.lockContentOffset = true - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() self.contextMenuWindow = ContextMenuWindow() self.contextMenuVC = ContextMenuVC( @@ -1171,20 +1100,12 @@ extension ConversationVC: self?.contextMenuWindow = nil self?.scrollButton.alpha = 0 - UIView.animate( - withDuration: 0.25, - animations: { self?.updateScrollToBottom() }, - completion: { _ in - guard let contentOffset: CGPoint = self?.tableView.contentOffset else { return } - - // Unlock the contentOffset so everything will be in the right - // place when we return - self?.tableView.lockContentOffset = false - self?.tableView.setContentOffset(contentOffset, animated: false) - } - ) + UIView.animate(withDuration: 0.25) { + self?.updateScrollToBottom() + } } + self.hideSearchUI() self.contextMenuWindow?.themeBackgroundColor = .clear self.contextMenuWindow?.rootViewController = self.contextMenuVC self.contextMenuWindow?.overrideUserInterfaceStyle = ThemeManager.currentTheme.interfaceStyle @@ -1196,6 +1117,8 @@ extension ConversationVC: cell: UITableViewCell, cellLocation: CGPoint ) { + self.hideSearchUI() + // For call info messages show the "call missed" modal guard cellViewModel.variant != .infoCall else { // If the failure was due to the mic permission being denied then we want to show the permission modal, @@ -1408,24 +1331,7 @@ extension ConversationVC: ) if let viewController: UIViewController = viewController { - /// Delay becoming the first responder to make the return transition a little nicer (allows - /// for the footer on the detail view to slide out rather than instantly vanish) - self.delayFirstResponder = true - - /// Dismiss the input before starting the presentation to make everything look smoother - self.resignFirstResponder() - - /// Delay the actual presentation to give the 'resignFirstResponder' call the chance to complete - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in - /// Lock the contentOffset of the tableView so the transition doesn't look buggy - self?.tableView.lockContentOffset = true - - self?.present(viewController, animated: true) { [weak self] in - // Unlock the contentOffset so everything will be in the right - // place when we return - self?.tableView.lockContentOffset = false - } - } + present(viewController, animated: true) } } @@ -1518,13 +1424,13 @@ extension ConversationVC: let quoteViewContainsTouch: Bool = (visibleCell.quoteView?.bounds.contains(quotePoint) == true) let linkPreviewViewContainsTouch: Bool = (visibleCell.linkPreviewView?.previewView.bounds.contains(linkPreviewPoint) == true) - switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quotedInfo, cellViewModel.linkPreview) { + switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quoteViewModel, cellViewModel.linkPreview) { // If the message contains both links and a quote, and the user tapped on the quote; OR the // message only contained a quote, then scroll to the quote - case (true, true, _, .some(let quotedInfo), _), (false, _, _, .some(let quotedInfo), _): + case (true, true, _, .some(let quoteViewModel), _), (false, _, _, .some(let quoteViewModel), _): let maybeTimestampMs: Int64? = viewModel.dependencies[singleton: .storage].read { db in try Interaction - .filter(id: quotedInfo.quotedInteractionId) + .filter(id: quoteViewModel.quotedInteractionId) .select(.timestampMs) .asRequest(of: Int64.self) .fetchOne(db) @@ -1536,7 +1442,7 @@ extension ConversationVC: self.scrollToInteractionIfNeeded( with: Interaction.TimestampInfo( - id: quotedInfo.quotedInteractionId, + id: quoteViewModel.quotedInteractionId, timestampMs: timestampMs ), focusBehaviour: .highlight, @@ -1595,23 +1501,15 @@ extension ConversationVC: hasCloseButton: true, onConfirm: { [weak self] modal in UIApplication.shared.open(url, options: [:], completionHandler: nil) - self?.showInputAccessoryView() - modal.dismiss(animated: true) }, onCancel: { [weak self] modal in UIPasteboard.general.string = url.absoluteString - - modal.dismiss(animated: true) { - self?.showInputAccessoryView() - } } ) ) - self.present(modal, animated: true) { [weak self] in - self?.hideInputAccessoryView() - } + self.present(modal, animated: true) } func handleReplyButtonTapped(for cellViewModel: MessageViewModel) { @@ -1625,7 +1523,7 @@ extension ConversationVC: let dependencies: Dependencies = viewModel.dependencies - let (info, _) = ProfilePictureView.getProfilePictureInfo( + let (info, _) = ProfilePictureView.Info.generateInfoFrom( size: .hero, publicKey: cellViewModel.authorId, threadVariant: .contact, // Always show the display picture in 'contact' mode @@ -1683,7 +1581,6 @@ extension ConversationVC: return cellViewModel.profile?.blocksCommunityMessageRequests != true }() - self.hideInputAccessoryView() let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModal( info: .init( @@ -1706,11 +1603,7 @@ extension ConversationVC: dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .generic, dismissType: .single, - beforePresented: { [weak self] in - self?.hideInputAccessoryView() - }, afterClosed: { [weak self] in - self?.showInputAccessoryView() self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, presenting: { modal in @@ -1719,10 +1612,7 @@ extension ConversationVC: ) } ), - dataManager: dependencies[singleton: .imageDataManager], - afterClosed: { [weak self] in - self?.showInputAccessoryView() - } + dataManager: dependencies[singleton: .imageDataManager] ) ) present(userProfileModal, animated: true, completion: nil) @@ -2220,17 +2110,12 @@ extension ConversationVC: } func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) { - hideInputAccessoryView() - let emojiPicker = EmojiPickerSheet( completionHandler: { [weak self] emoji in guard let emoji: EmojiWithSkinTones = emoji else { return } self?.react(cellViewModel, with: emoji) }, - dismissHandler: { [weak self] in - self?.showInputAccessoryView() - }, using: self.viewModel.dependencies ) @@ -2420,22 +2305,36 @@ extension ConversationVC: } func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { - let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( - threadId: self.viewModel.threadData.threadId, - authorId: cellViewModel.authorId, - variant: cellViewModel.variant, - body: cellViewModel.body, - timestampMs: cellViewModel.timestampMs, - attachments: cellViewModel.attachments, - linkPreviewAttachment: cellViewModel.linkPreviewAttachment, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []) - ) + guard + cellViewModel.variant == .standardOutgoing || + cellViewModel.variant == .standardIncoming + else { return } + guard + (cellViewModel.body ?? "")?.isEmpty == false || + cellViewModel.attachments?.isEmpty == false + else { return } - guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } + let targetAttachment: Attachment? = ( + cellViewModel.attachments?.first ?? + cellViewModel.linkPreviewAttachment + ) - snInputView.quoteDraftInfo = ( - model: quoteDraft, - isOutgoing: (cellViewModel.variant == .standardOutgoing) + snInputView.quoteDraft = QuoteViewModel( + mode: .draft, + direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), + rowId: -1, + interactionId: nil, + authorId: cellViewModel.authorId, + timestampMs: cellViewModel.timestampMs, + quotedInteractionId: cellViewModel.id, + quotedInteractionIsDeleted: cellViewModel.variant.isDeletedMessage, + quotedText: cellViewModel.body, + quotedAttachmentInfo: targetAttachment?.quoteAttachmentInfo(using: self.viewModel.dependencies), + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: self.viewModel.threadData.threadVariant, + using: self.viewModel.dependencies + ) ) // If the `MessageInfoViewController` is visible then we want to show the keyboard after @@ -2605,9 +2504,6 @@ extension ConversationVC: } } ) - }, - afterClosed: { [weak self] in - self?.becomeFirstResponder() } ) ) @@ -2679,13 +2575,9 @@ extension ConversationVC: self?.sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))) } - self?.showInputAccessoryView() - self?.becomeFirstResponder() self?.documentHandler = nil }, wasCancelled: { [weak self] _ in - self?.showInputAccessoryView() - self?.becomeFirstResponder() self?.documentHandler = nil } ) @@ -2813,12 +2705,9 @@ extension ConversationVC: } } ) - - self?.becomeFirstResponder() }, afterClosed: { [weak self] in completion?() - self?.becomeFirstResponder() } ) ) @@ -2891,11 +2780,6 @@ extension ConversationVC: } } ) - - self?.becomeFirstResponder() - }, - afterClosed: { [weak self] in - self?.becomeFirstResponder() } ) ) @@ -3441,72 +3325,19 @@ extension ConversationVC: MediaPresentationContextProvider { .first(where: { $0.attachment.id == mediaId }) guard - let messageCell: VisibleMessageCell = maybeMessageCell, let targetView: MediaView = maybeTargetView, let mediaSuperview: UIView = targetView.superview else { return nil } - let cornerRadius: CGFloat - let cornerMask: CACornerMask - let presentationFrame: CGRect = coordinateSpace.convert(targetView.frame, from: mediaSuperview) - let frameInBubble: CGRect = messageCell.bubbleView.convert(targetView.frame, from: mediaSuperview) - - if messageCell.bubbleView.bounds == targetView.bounds { - cornerRadius = messageCell.bubbleView.layer.cornerRadius - cornerMask = messageCell.bubbleView.layer.maskedCorners - } - else { - // If the frames don't match then assume it's either multiple images or there is a caption - // and determine which corners need to be rounded - cornerRadius = messageCell.bubbleView.layer.cornerRadius - - var newCornerMask = CACornerMask() - let cellMaskedCorners: CACornerMask = messageCell.bubbleView.layer.maskedCorners - - if - cellMaskedCorners.contains(.layerMinXMinYCorner) && - frameInBubble.minX < CGFloat.leastNonzeroMagnitude && - frameInBubble.minY < CGFloat.leastNonzeroMagnitude - { - newCornerMask.insert(.layerMinXMinYCorner) - } - - if - cellMaskedCorners.contains(.layerMaxXMinYCorner) && - abs(frameInBubble.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && - frameInBubble.minY < CGFloat.leastNonzeroMagnitude - { - newCornerMask.insert(.layerMaxXMinYCorner) - } - - if - cellMaskedCorners.contains(.layerMinXMaxYCorner) && - frameInBubble.minX < CGFloat.leastNonzeroMagnitude && - abs(frameInBubble.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude - { - newCornerMask.insert(.layerMinXMaxYCorner) - } - - if - cellMaskedCorners.contains(.layerMaxXMaxYCorner) && - abs(frameInBubble.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && - abs(frameInBubble.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude - { - newCornerMask.insert(.layerMaxXMaxYCorner) - } - - cornerMask = newCornerMask - } - return MediaPresentationContext( mediaView: targetView.imageView, - presentationFrame: presentationFrame, - cornerRadius: cornerRadius, - cornerMask: cornerMask + presentationFrame: coordinateSpace.convert(targetView.frame, from: mediaSuperview), + cornerRadius: targetView.layer.cornerRadius, + cornerMask: targetView.layer.maskedCorners ) } - - func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { - return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + + func lowestViewToRenderAboveContent() -> UIView? { + return inputBackgroundView } } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 7ec260b5b3..529c189075 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -12,6 +12,7 @@ import SignalUtilitiesKit final class ConversationVC: BaseVC, LibSessionRespondingViewController, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { private static let loadingHeaderHeight: CGFloat = 40 + static let expandedAttachmentButtonSpacing: CGFloat = 4 internal let viewModel: ConversationViewModel private var dataChangeObservable: DatabaseCancellable? { @@ -50,43 +51,24 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa var documentHandler: DocumentPickerHandler? // Mentions - var currentMentionStartIndex: String.Index? - var mentions: [MentionInfo] = [] + @MainActor var currentMentionStartIndex: String.Index? + @MainActor var mentions: [MentionSelectionView.ViewModel] = [] // Scrolling & paging var isUserScrolling = false var hasPerformedInitialScroll = false var didFinishInitialLayout = false - var scrollDistanceToBottomBeforeUpdate: CGFloat? - var baselineKeyboardHeight: CGFloat = 0 + private var lastBottomInset: CGFloat = 0 + private var shouldUpdateInsets: Bool = true /// These flags are true between `viewDid/Will Appear/Disappear` and is used to prevent keyboard changes /// from trying to animate (as the animations can cause buggy transitions) - var viewIsDisappearing = false - var viewIsAppearing = false - var lastPresentedViewController: UIViewController? + var viewIsAppearing: Bool = false // Reaction var currentReactionListSheet: ReactionListSheet? var reactionExpandedMessageIds: Set = [] - /// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with - /// custom transitions from preventing them from being buggy - var delayFirstResponder: Bool = false - override var canBecomeFirstResponder: Bool { - !delayFirstResponder && - - // Need to return false during the swap between threads to prevent keyboard dismissal - !isReplacingThread - } - - override var inputAccessoryView: UIView? { - return (viewModel.threadData.threadCanWrite == true && isShowingSearchUI ? - searchController.resultsBar : - snInputView - ) - } - /// The height of the visible part of the table view, i.e. the distance from the navigation bar (where the table view's origin is) /// to the top of the input view (`tableView.adjustedContentInset.bottom`). var tableViewUnobscuredHeight: CGFloat { @@ -125,11 +107,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa var lastKnownKeyboardFrame: CGRect? - var scrollButtonBottomConstraint: NSLayoutConstraint? - var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? - var messageRequestsViewBotomConstraint: NSLayoutConstraint? - var legacyGroupsFooterViewViewTopConstraint: NSLayoutConstraint? - lazy var titleView: ConversationTitleView = { let result: ConversationTitleView = ConversationTitleView(using: viewModel.dependencies) let tapGestureRecognizer = UITapGestureRecognizer( @@ -141,21 +118,12 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return result }() - lazy var tableView: InsetLockableTableView = { - let result: InsetLockableTableView = InsetLockableTableView() + lazy var tableView: AfterLayoutCallbackTableView = { + let result: AfterLayoutCallbackTableView = AfterLayoutCallbackTableView() result.separatorStyle = .none result.themeBackgroundColor = .clear result.showsVerticalScrollIndicator = false result.keyboardDismissMode = .interactive - result.contentInset = UIEdgeInsets( - top: 0, - leading: 0, - bottom: (viewModel.threadData.threadCanWrite == true ? - Values.mediumSpacing : - (Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0)) - ), - trailing: 0 - ) result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self) result.register(view: DateHeaderCell.self) result.register(view: UnreadMarkerCell.self) @@ -167,17 +135,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa result.sectionFooterHeight = 0 result.dataSource = self result.delegate = self - result.contentInsetAdjustmentBehavior = .never // We custom handle it to prevent bugs + result.contentInsetAdjustmentBehavior = .never /// We custom handle it return result }() - lazy var snInputView: InputView = InputView( - threadVariant: self.viewModel.initialThreadVariant, - delegate: self, - using: self.viewModel.dependencies - ) - lazy var unreadCountView: UIView = { let result: UIView = UIView() result.themeBackgroundColor = .backgroundSecondary @@ -305,20 +267,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return result }() - - lazy var footerControlsStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.translatesAutoresizingMaskIntoConstraints = false - result.axis = .vertical - result.alignment = .trailing - result.distribution = .equalSpacing - result.spacing = 10 - result.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) - result.isLayoutMarginsRelativeArrangement = true - - return result - }() - + lazy var scrollButton: RoundIconButton = { let result: RoundIconButton = RoundIconButton( image: UIImage(named: "ic_chevron_down")? @@ -335,6 +284,19 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return result }() + + lazy var footerControlsStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + legacyGroupsRecreateGroupView, + messageRequestFooterView, + snInputView + ]) + result.axis = .vertical + result.alignment = .fill + result.distribution = .fill + + return result + }() lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView( threadVariant: self.viewModel.threadData.threadVariant, @@ -354,6 +316,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa viewModel.threadData.currentUserIsClosedGroupAdmin != true ) + result.addSubview(legacyGroupsFooterButton) + return result }() @@ -365,13 +329,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa .backgroundPrimary, .backgroundPrimary ] - - return result - }() - - private lazy var legacyGroupsInputBackgroundView: UIView = { - let result: UIView = UIView() - result.themeBackgroundColor = .backgroundPrimary + result.isHidden = legacyGroupsRecreateGroupView.isHidden return result }() @@ -386,6 +344,105 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return result }() + lazy var snInputView: InputView = InputView( + delegate: self, + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: self.viewModel.initialThreadVariant, + using: self.viewModel.dependencies + ), + dataManager: self.viewModel.dependencies[singleton: .imageDataManager], + sessionProState: self.viewModel.dependencies[singleton: .sessionProState] + ) + + lazy var inputBackgroundView: UIView = { + let result: UIView = UIView() + + let backgroundView: UIView = UIView() + backgroundView.themeBackgroundColor = .backgroundSecondary + backgroundView.alpha = Values.lowOpacity + result.addSubview(backgroundView) + backgroundView.pin(to: result) + + let blurView: UIVisualEffectView = UIVisualEffectView() + result.addSubview(blurView) + blurView.pin(to: result) + + ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _, _ in + blurView?.effect = UIBlurEffect(style: theme.blurStyle) + } + + return result + }() + + lazy var attachmentButtonStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + gifButton, + documentButton, + libraryButton, + cameraButton + ]) + result.axis = .vertical + result.spacing = 4 + result.alignment = .fill + result.distribution = .fill + result.alpha = 0 + result.isHidden = true /// Alpha for animation, hidden to avoid noisy UI hierarchy + + return result + }() + + lazy var gifButton: UIView = { + let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), hasOpaqueBackground: true) { [weak self] in + self?.handleGIFButtonTapped() + } + button.accessibilityIdentifier = "GIF button" + button.isAccessibilityElement = true + + let result: UIView = InputViewButton.container(for: button) + result.isHidden = true + + return result + }() + lazy var documentButton: UIView = { + let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), hasOpaqueBackground: true) { [weak self] in + self?.handleDocumentButtonTapped() + } + button.accessibilityIdentifier = "Documents folder" + button.accessibilityLabel = "Files" + button.isAccessibilityElement = true + + let result: UIView = InputViewButton.container(for: button) + result.isHidden = true + + return result + }() + lazy var libraryButton: UIView = { + let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), hasOpaqueBackground: true) { [weak self] in + self?.handleLibraryButtonTapped() + } + button.accessibilityIdentifier = "Images folder" + button.accessibilityLabel = "Photo library" + button.isAccessibilityElement = true + + let result: UIView = InputViewButton.container(for: button) + result.isHidden = true + + return result + }() + lazy var cameraButton: UIView = { + let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), hasOpaqueBackground: true) { [weak self] in + self?.handleCameraButtonTapped() + } + button.accessibilityIdentifier = "Select camera button" + button.accessibilityLabel = "Camera" + button.isAccessibilityElement = true + + let result: UIView = InputViewButton.container(for: button) + result.isHidden = true + + return result + }() + // Handle taps outside of tableview cell private lazy var tableViewTapGesture: UITapGestureRecognizer = { let result: UITapGestureRecognizer = UITapGestureRecognizer() @@ -469,42 +526,16 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa tableView.pin(to: view) // Message requests view & scroll to bottom + view.addSubview(inputBackgroundView) + view.addSubview(legacyGroupsFadeView) + view.addSubview(footerControlsStackView) view.addSubview(scrollButton) view.addSubview(stateStackView) - view.addSubview(messageRequestFooterView) - view.addSubview(legacyGroupsRecreateGroupView) - - legacyGroupsRecreateGroupView.addSubview(legacyGroupsInputBackgroundView) - legacyGroupsRecreateGroupView.addSubview(legacyGroupsFadeView) - legacyGroupsRecreateGroupView.addSubview(legacyGroupsFooterButton) + view.addSubview(attachmentButtonStackView) stateStackView.pin(.top, to: .top, of: view, withInset: 0) stateStackView.pin(.leading, to: .leading, of: view, withInset: 0) stateStackView.pin(.trailing, to: .trailing, of: view, withInset: 0) - - scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20) - messageRequestFooterView.pin(.leading, to: .leading, of: view, withInset: 16) - messageRequestFooterView.pin(.trailing, to: .trailing, of: view, withInset: -16) - self.messageRequestsViewBotomConstraint = messageRequestFooterView.pin(.bottom, to: .bottom, of: view, withInset: -16) - self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) - self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint - self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestFooterView, withInset: -4) - - legacyGroupsFooterViewViewTopConstraint = legacyGroupsRecreateGroupView - .pin(.top, to: .bottom, of: view, withInset: -Values.footerGradientHeight(window: UIApplication.shared.keyWindow)) - legacyGroupsRecreateGroupView.pin(.leading, to: .leading, of: view) - legacyGroupsRecreateGroupView.pin(.trailing, to: .trailing, of: view) - legacyGroupsRecreateGroupView.pin(.bottom, to: .bottom, of: view) - legacyGroupsFadeView.pin(.top, to: .top, of: legacyGroupsRecreateGroupView) - legacyGroupsFadeView.pin(.leading, to: .leading, of: legacyGroupsRecreateGroupView) - legacyGroupsFadeView.pin(.trailing, to: .trailing, of: legacyGroupsRecreateGroupView) - legacyGroupsInputBackgroundView.pin(.top, to: .bottom, of: legacyGroupsFadeView) - legacyGroupsInputBackgroundView.pin(.leading, to: .leading, of: legacyGroupsRecreateGroupView) - legacyGroupsInputBackgroundView.pin(.trailing, to: .trailing, of: legacyGroupsRecreateGroupView) - legacyGroupsInputBackgroundView.pin(.bottom, to: .bottom, of: legacyGroupsRecreateGroupView) - legacyGroupsFooterButton.pin(.top, to: .top, of: legacyGroupsFadeView, withInset: 32) - legacyGroupsFooterButton.pin(.leading, to: .leading, of: legacyGroupsFadeView, withInset: 16) - legacyGroupsFooterButton.pin(.trailing, to: .trailing, of: legacyGroupsFadeView, withInset: -16) // Unread count view view.addSubview(unreadCountView) @@ -515,6 +546,35 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) + + footerControlsStackView.pin(.leading, to: .leading, of: view) + footerControlsStackView.pin(.trailing, to: .trailing, of: view) + footerControlsStackView.pin(.bottom, to: .top, of: view.keyboardLayoutGuide) + + legacyGroupsFooterButton.pin(.top, to: .top, of: legacyGroupsRecreateGroupView, withInset: 32) + legacyGroupsFooterButton.pin(.leading, to: .leading, of: legacyGroupsRecreateGroupView, withInset: 16) + legacyGroupsFooterButton.pin(.trailing, to: .trailing, of: legacyGroupsRecreateGroupView, withInset: -16) + legacyGroupsFooterButton.pin(.bottom, to: .bottom, of: legacyGroupsRecreateGroupView, withInset: -16) + + inputBackgroundView.pin(.top, to: .top, of: snInputView.inputContainerForBackground) + inputBackgroundView.pin(.leading, to: .leading, of: view) + inputBackgroundView.pin(.trailing, to: .trailing, of: view) + inputBackgroundView.pin(.bottom, to: .bottom, of: view) + + legacyGroupsFadeView.pin(.top, to: .top, of: legacyGroupsRecreateGroupView) + legacyGroupsFadeView.pin(.leading, to: .leading, of: legacyGroupsRecreateGroupView) + legacyGroupsFadeView.pin(.trailing, to: .trailing, of: legacyGroupsRecreateGroupView) + legacyGroupsFadeView.pin(.bottom, to: .bottom, of: view) + + scrollButton.center(.horizontal, in: snInputView.sendButton) + scrollButton.pin(.bottom, to: .top, of: footerControlsStackView, withInset: -12) + + attachmentButtonStackView.pin(.leading, to: .leading, of: snInputView.attachmentsButtonContainer) + attachmentButtonStackView.pin(.trailing, to: .trailing, of: snInputView.attachmentsButtonContainer) + attachmentButtonStackView.pin(.bottom, to: .top, of: snInputView.attachmentsButtonContainer, withInset: -attachmentButtonStackView.spacing) + + // Gesture + view.addGestureRecognizer(tableViewTapGesture) // Notifications NotificationCenter.default.addObserver( @@ -528,27 +588,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil ) - - // Observe keyboard notifications - let keyboardNotifications: [Notification.Name] = [ - UIResponder.keyboardWillShowNotification, - UIResponder.keyboardDidShowNotification, - UIResponder.keyboardWillChangeFrameNotification, - UIResponder.keyboardDidChangeFrameNotification, - UIResponder.keyboardWillHideNotification, - UIResponder.keyboardDidHideNotification - ] - keyboardNotifications.forEach { notification in - NotificationCenter.default.addObserver( - self, - selector: #selector(handleKeyboardNotification(_:)), - name: notification, - object: nil - ) - } - - // Gesture - view.addGestureRecognizer(tableViewTapGesture) self.viewModel.navigatableState.setupBindings(viewController: self, disposables: &self.viewModel.disposables) @@ -566,61 +605,67 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa /// If the view is removed and readded to the view hierarchy then `viewWillDisappear` will be called but `viewDidDisappear` /// **won't**, as a result `viewIsDisappearing` would never get set to `false` - do so here to handle this case - viewIsDisappearing = false viewIsAppearing = true + shouldUpdateInsets = true } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if delayFirstResponder || isShowingSearchUI { - delayFirstResponder = false - - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in - (self?.isShowingSearchUI == false ? - self : - self?.searchController.uiSearchController.searchBar - )?.becomeFirstResponder() - } - } - else if !self.isFirstResponder && hasLoadedInitialThreadData && lastPresentedViewController == nil { - // After we have loaded the initial data if the user starts and cancels the interactive pop - // gesture the input view will disappear (but if we are returning from a presented view controller - // the keyboard will automatically reappear and calling this will break the first responder state - // so don't do it in that case) - self.becomeFirstResponder() - } + // Reset to current state to avoid adjustments when returning to this VC + lastBottomInset = tableView.contentInset.bottom - recoverInputView { [weak self] in - // Flag that the initial layout has been completed (the flag blocks and unblocks a number - // of different behaviours) - self?.didFinishInitialLayout = true - self?.viewIsAppearing = false - self?.lastPresentedViewController = nil + // Flag that the initial layout has been completed (the flag blocks and unblocks a number + // of different behaviours) + self.didFinishInitialLayout = true + self.viewIsAppearing = false - // Show inputview keyboard - if self?.hasPendingInputKeyboardPresentationEvent == true { - // Added 0.1 delay to remove inputview stutter animation glitch while keyboard is animating up - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + // Show inputview keyboard + if self.hasPendingInputKeyboardPresentationEvent { + // Added 0.1 delay to remove inputview stutter animation glitch while keyboard is animating up + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + if self?.isShowingSearchUI == false { _ = self?.snInputView.becomeFirstResponder() } - self?.hasPendingInputKeyboardPresentationEvent = false + else { + self?.searchController.uiSearchController.searchBar.becomeFirstResponder() + } } + self.hasPendingInputKeyboardPresentationEvent = false } } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + /// Don't update insets while view is transitioning/hidden + guard shouldUpdateInsets, footerControlsStackView.frame != .zero else { return } + + let bottomInset: CGFloat = ( + (tableView.frame.height - footerControlsStackView.frame.minY) + + Values.smallSpacing + ) + + /// Only proceed if the insert actually changed + guard abs(bottomInset - lastBottomInset) > 0.5 else { return } + + tableView.contentInset.bottom = bottomInset + tableView.verticalScrollIndicatorInsets.bottom = bottomInset + + lastBottomInset = bottomInset + } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - viewIsDisappearing = true - lastPresentedViewController = self.presentedViewController + shouldUpdateInsets = false // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard // to appear to remain focussed) guard !isReplacingThread else { return } stopObservingChanges() - viewModel.updateDraft(to: replaceMentions(in: snInputView.text)) + viewModel.updateDraft(to: mentions.update(snInputView.text)) inputAccessoryView?.resignFirstResponder() } @@ -628,7 +673,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa super.viewDidDisappear(animated) hasReloadedThreadDataAfterDisappearance = false - viewIsDisappearing = false /// If the user just created this thread but didn't send a message or the conversation is marked as hidden then we want to delete the /// "shadow" thread since it's not actually in use (this is to prevent it from taking up database space or unintentionally getting synced @@ -664,17 +708,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa DispatchQueue.global(qos: .background).async { [weak self] in self?.viewModel.pagedDataObserver?.resume() } - - recoverInputView() - - if !isShowingSearchUI && self.presentedViewController == nil { - if !self.isFirstResponder { - self.becomeFirstResponder() - } - else { - self.reloadInputViews() - } - } } @objc func applicationDidResignActive(_ notification: Notification) { @@ -836,14 +869,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || viewModel.threadData.closedGroupAdminProfile != updatedThreadData.closedGroupAdminProfile { - if updatedThreadData.threadCanWrite == true { - self.showInputAccessoryView() - } else if updatedThreadData.threadCanWrite == false && updatedThreadData.threadVariant != .community { - self.hideInputAccessoryView() - } - - let messageRequestsViewWasVisible: Bool = (self.messageRequestFooterView.isHidden == false) - UIView.animate(withDuration: 0.3) { [weak self] in self?.messageRequestFooterView.update( threadVariant: updatedThreadData.threadVariant, @@ -852,25 +877,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true), closedGroupAdminProfile: updatedThreadData.closedGroupAdminProfile ) - self?.scrollButtonMessageRequestsBottomConstraint?.isActive = ( - self?.messageRequestFooterView.isHidden == false - ) - self?.scrollButtonBottomConstraint?.isActive = ( - self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false - ) - - // Update the table content inset and offset to account for - // the dissapearance of the messageRequestsView - if messageRequestsViewWasVisible != (self?.messageRequestFooterView.isHidden == false) { - let messageRequestsOffset: CGFloat = (self?.messageRequestFooterView.bounds.height ?? 0) - let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) - self?.tableView.contentInset = UIEdgeInsets( - top: 0, - leading: 0, - bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), - trailing: 0 - ) - } } } @@ -917,14 +923,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let (string, mentions) = MentionUtilities.getMentions( in: draft, currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), - displayNameRetriever: { [dependencies = viewModel.dependencies] sessionId, _ in - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - return Profile.displayNameNoFallback( - id: sessionId, - threadVariant: updatedThreadData.threadVariant, - using: dependencies - ) - } + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: updatedThreadData.threadVariant, + using: viewModel.dependencies + ) ) snInputView.text = string snInputView.updateNumberOfCharactersLeft(draft) @@ -932,26 +934,40 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Fetch the mention info asynchronously if !mentions.isEmpty { viewModel.dependencies[singleton: .storage].readAsync( - retrieve: { db in - try Profile + retrieve: { [openGroupManager = viewModel.dependencies[singleton: .openGroupManager]] db -> ([Profile], [GroupMember]) in + let profiles: [Profile] = try Profile .filter(ids: mentions.map { $0.profileId }) .fetchAll(db) + + guard + let server: String = updatedThreadData.openGroupServer, + let roomToken: String = updatedThreadData.openGroupRoomToken + else { return (profiles, []) } + + let adminModMembers: [GroupMember] = try openGroupManager.membersWhere( + db, + currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), + .groupIds([OpenGroup.idFor(roomToken: roomToken, server: server)]), + .publicKeys(profiles.map { $0.id }), + .roles([.moderator, .admin]) + ) + + return (profiles, adminModMembers) }, - completion: { [weak self] result in + completion: { [weak self, dependencies = viewModel.dependencies] result in guard let self = self, - case let .success(profiles) = result + case .success((let profiles, let adminModMembers)) = result else { return } self.mentions = self.mentions.appending( - contentsOf: profiles.map { - MentionInfo( - profile: $0, - threadVariant: updatedThreadData.threadVariant, - openGroupServer: updatedThreadData.openGroupServer, - openGroupRoomToken: updatedThreadData.openGroupRoomToken - ) - } + contentsOf: MentionSelectionView.ViewModel.mentions( + profiles: profiles, + threadVariant: updatedThreadData.threadVariant, + currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), + adminModMembers: adminModMembers, + using: dependencies + ) ) } ) @@ -960,17 +976,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Now we have done all the needed diffs update the viewModel with the latest data self.viewModel.updateThreadData(updatedThreadData) - - /// **Note:** This needs to happen **after** we have update the viewModel's thread data (otherwise the `inputAccessoryView` - /// won't be generated correctly) - if initialLoad || viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite { - if !self.isFirstResponder { - self.becomeFirstResponder() - } - else { - self.reloadInputViews() - } - } } private func handleInteractionUpdates( @@ -1481,124 +1486,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } } } - - // MARK: - Keyboard Avoidance - - @objc func handleKeyboardNotification(_ notification: Notification) { - guard - !viewIsDisappearing, - let userInfo: [AnyHashable: Any] = notification.userInfo, - var keyboardEndFrame: CGRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - else { return } - - // If reduce motion+crossfade transitions is on, in iOS 14 UIKit sends out a keyboard end frame - // of CGRect zero. This breaks the math below. - // - // If our keyboard end frame is CGRectZero, build a fake rect that's translated off the bottom edge. - if keyboardEndFrame == .zero { - keyboardEndFrame = CGRect( - x: UIScreen.main.bounds.minX, - y: UIScreen.main.bounds.maxY, - width: UIScreen.main.bounds.width, - height: 0 - ) - } - - // If we explicitly can't write to the thread then the input will be hidden but they keyboard - // still reports that it takes up size, so just report 0 height in that case - if viewModel.threadData.threadCanWrite == false && viewModel.threadData.threadVariant != .community { - keyboardEndFrame = CGRect( - x: UIScreen.main.bounds.minX, - y: UIScreen.main.bounds.maxY, - width: UIScreen.main.bounds.width, - height: 0 - ) - } - - // No nothing if there was no change - // Note: there is a bug on iOS 15.X for iPhone 6/6s where the converted frame is not accurate. - // In iOS 16.1 and later, the keyboard notification object is the screen the keyboard appears on. - // This is a workaround to fix the issue - let fromCoordinateSpace: UICoordinateSpace? = { - if let screen = (notification.object as? UIScreen) { - return screen.coordinateSpace - } else { - var result: UIView? = self.view.superview - while result != nil && result?.frame != UIScreen.main.bounds { - result = result?.superview - } - return result - } - }() - let keyboardEndFrameConverted: CGRect = fromCoordinateSpace?.convert(keyboardEndFrame, to: self.view) ?? keyboardEndFrame - guard keyboardEndFrameConverted != lastKnownKeyboardFrame else { return } - - self.lastKnownKeyboardFrame = keyboardEndFrameConverted - - // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 - // and https://stackoverflow.com/a/25260930 to better understand what we are - // doing with the UIViewAnimationOptions - let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) - let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) - let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) - - guard didFinishInitialLayout && !viewIsAppearing, duration > 0, !UIAccessibility.isReduceMotionEnabled else { - // UIKit by default (sometimes? never?) animates all changes in response to keyboard events. - // We want to suppress those animations if the view isn't visible, - // otherwise presentation animations don't work properly. - UIView.performWithoutAnimation { - self.updateKeyboardAvoidance() - } - return - } - - UIView.animate( - withDuration: duration, - delay: 0, - options: options, - animations: { [weak self] in - self?.updateKeyboardAvoidance() - self?.view.layoutIfNeeded() - }, - completion: nil - ) - } - - private func updateKeyboardAvoidance() { - guard let lastKnownKeyboardFrame: CGRect = self.lastKnownKeyboardFrame else { return } - - let legacyGroupsFooterOffset: CGFloat = (legacyGroupsRecreateGroupView.isHidden ? 0 : - legacyGroupsFadeView.bounds.height) // Intentionally want the height of 'legacyGroupsFadeView' - let messageRequestsOffset: CGFloat = (messageRequestFooterView.isHidden ? 0 : - messageRequestFooterView.bounds.height) - let viewIntersection = view.bounds.intersection(lastKnownKeyboardFrame) - let bottomOffset: CGFloat = (viewIntersection.isEmpty ? 0 : view.bounds.maxY - viewIntersection.minY) - let additionalPadding: CGFloat = (viewIntersection.isEmpty || legacyGroupsRecreateGroupView.isHidden ? Values.mediumSpacing : 0) - let contentInsets = UIEdgeInsets( - top: 0, - left: 0, - bottom: bottomOffset + additionalPadding + legacyGroupsFooterOffset + messageRequestsOffset, - right: 0 - ) - let insetDifference: CGFloat = (contentInsets.bottom - tableView.contentInset.bottom) - scrollButtonBottomConstraint?.constant = -(bottomOffset + 12) - messageRequestsViewBotomConstraint?.constant = -bottomOffset - legacyGroupsFooterViewViewTopConstraint?.constant = -(legacyGroupsFooterOffset + bottomOffset + (viewModel.threadData.threadCanWrite == false ? 16 : 0)) - tableView.contentInset = contentInsets - tableView.scrollIndicatorInsets = contentInsets - - // Only modify the contentOffset if we aren't at the bottom of the tableView, with a little - // buffer (if we are at the bottom then it'll automatically scroll for us and modifying the - // value will break things) - let tableViewBottom: CGFloat = (tableView.contentSize.height - tableView.bounds.height + tableView.contentInset.bottom) - - // Added `insetDifference > 0` to remove sudden table collapse and overscroll - if tableView.contentOffset.y < (tableViewBottom - 5) && insetDifference > 0 { - tableView.contentOffset.y += insetDifference - } - - updateScrollToBottom() - } // MARK: - General @@ -1906,22 +1793,27 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - Search - func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) { + func popAllConversationSettingsViews() { if presentedViewController != nil { dismiss(animated: true) { [weak self] in - guard let strongSelf: UIViewController = self else { return } + guard let self else { return } - self?.navigationController?.popToViewController(strongSelf, animated: true, completion: completionBlock) + navigationController?.popToViewController(self, animated: true, completion: nil) } } else { - navigationController?.popToViewController(self, animated: true, completion: completionBlock) + navigationController?.popToViewController(self, animated: true, completion: nil) } } func showSearchUI() { isShowingSearchUI = true + UIView.animate(withDuration: 0.3) { + self.footerControlsStackView.alpha = 0 + self.inputBackgroundView.alpha = 0 + } + // Search bar let searchBar = searchController.uiSearchController.searchBar searchBar.setUpSessionStyle() @@ -2003,8 +1895,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa ) searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil - becomeFirstResponder() - reloadInputViews() + UIView.animate(withDuration: 0.3) { + self.footerControlsStackView.alpha = 1 + self.inputBackgroundView.alpha = 1 + } } // Manually cancel the search and clear the text to remove hightlights diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 75549db6f3..aca7d42910 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -572,7 +572,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL, associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure() ), - AssociatedRecord( + AssociatedRecord( trackedAgainst: Quote.self, observedChanges: [ PagedData.ObservedChanges( @@ -584,13 +584,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold columns: [.state] ) ], - dataQuery: MessageViewModel.QuotedInfo.baseQuery( + dataQuery: QuoteViewModel.baseQuery( userSessionId: userSessionId, currentUserSessionIds: currentUserSessionIds ), - joinToPagedType: MessageViewModel.QuotedInfo.joinToViewModelQuerySQL(), - retrieveRowIdsForReferencedRowIds: MessageViewModel.QuotedInfo.createReferencedRowIdsRetriever(), - associateData: MessageViewModel.QuotedInfo.createAssociateDataClosure() + joinToPagedType: QuoteViewModel.joinToViewModelQuerySQL(), + retrieveRowIdsForReferencedRowIds: QuoteViewModel.createReferencedRowIdsRetriever(), + associateData: QuoteViewModel.createAssociateDataClosure() ) ], onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in @@ -714,7 +714,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold attachmentData: [Attachment]?, linkPreviewDraft: LinkPreviewDraft?, linkPreviewPreparedAttachment: PreparedAttachment?, - quoteModel: QuotedReplyModel? + quoteViewModel: QuoteViewModel? ) @ThreadSafeObject private var optimisticallyInsertedMessages: [UUID: OptimisticMessageData] = [:] @@ -725,7 +725,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold sentTimestampMs: Int64, attachments: [PendingAttachment]?, linkPreviewDraft: LinkPreviewDraft?, - quoteModel: QuotedReplyModel? + quoteViewModel: QuoteViewModel? ) async -> OptimisticMessageData { // Generate the optimistic data let optimisticMessageId: UUID = UUID() @@ -801,7 +801,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } }(), currentUserProfile: currentUserProfile, - quotedInfo: MessageViewModel.QuotedInfo(replyModel: quoteModel), + quoteViewModel: quoteViewModel,//MessageViewModel.QuotedInfo(replyModel: quoteModel), linkPreview: linkPreviewDraft.map { draft in LinkPreview( url: draft.urlString, @@ -820,7 +820,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold optimisticAttachments, linkPreviewDraft, linkPreviewPreparedAttachment, - quoteModel + quoteViewModel ) _optimisticallyInsertedMessages.performUpdate { $0.setting(optimisticMessageId, optimisticData) } @@ -844,7 +844,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold $0.attachmentData, $0.linkPreviewDraft, $0.linkPreviewPreparedAttachment, - $0.quoteModel + $0.quoteViewModel ) } ) @@ -907,42 +907,19 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - Mentions - public func mentions(for query: String = "") -> [MentionInfo] { - let threadData: SessionThreadViewModel = self.internalThreadData + public func mentions(for query: String = "") async throws -> [MentionSelectionView.ViewModel] { + let userSessionId: SessionId = dependencies[cache: .general].sessionId - return dependencies[singleton: .storage] - .read { [weak self, dependencies] db -> [MentionInfo] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) - let capabilities: Set = (threadData.threadVariant != .community ? - nil : - try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == threadData.openGroupServer) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) - ) - .defaulting(to: []) - let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? - [.blinded15, .blinded25] : - [.standard] - ) - - return (try MentionInfo - .query( - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - targetPrefixes: targetPrefixes, - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] - ), - pattern: pattern - )? - .fetchAll(db)) - .defaulting(to: []) - } - .defaulting(to: []) + return try await MentionSelectionView.ViewModel.mentions( + for: query, + threadId: self.internalThreadData.threadId, + threadVariant: self.internalThreadData.threadVariant, + currentUserSessionIds: (threadData.currentUserSessionIds ?? [userSessionId.hexString]), + communityInfo: self.internalThreadData.openGroupServer.map { server in + self.internalThreadData.openGroupRoomToken.map { (server: server, roomToken: $0) } + }, + using: dependencies + ) } // MARK: - Functions diff --git a/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift b/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift index 313083d2e9..dccf5cb60b 100644 --- a/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift +++ b/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit class EmojiPickerSheet: BaseVC { private let dependencies: Dependencies let completionHandler: (EmojiWithSkinTones?) -> Void - let dismissHandler: () -> Void + let dismissHandler: (() -> Void)? // MARK: Components @@ -52,7 +52,11 @@ class EmojiPickerSheet: BaseVC { // MARK: - Initialization - init(completionHandler: @escaping (EmojiWithSkinTones?) -> Void, dismissHandler: @escaping () -> Void, using dependencies: Dependencies) { + init( + completionHandler: @escaping (EmojiWithSkinTones?) -> Void, + dismissHandler: (() -> Void)? = nil, + using dependencies: Dependencies + ) { self.dependencies = dependencies self.completionHandler = completionHandler self.dismissHandler = dismissHandler diff --git a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift deleted file mode 100644 index 7a86f0e68c..0000000000 --- a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import SessionUIKit - -final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate { - private weak var delegate: ExpandingAttachmentsButtonDelegate? - private var isExpanded = false { didSet { expandOrCollapse() } } - - public var isSoftDisabled = false { - didSet { - gifButton.isSoftDisabled = isSoftDisabled - documentButton.isSoftDisabled = isSoftDisabled - libraryButton.isSoftDisabled = isSoftDisabled - cameraButton.isSoftDisabled = isSoftDisabled - mainButton.isSoftDisabled = isSoftDisabled - } - } - - override var isUserInteractionEnabled: Bool { - didSet { - gifButton.isUserInteractionEnabled = isUserInteractionEnabled - documentButton.isUserInteractionEnabled = isUserInteractionEnabled - libraryButton.isUserInteractionEnabled = isUserInteractionEnabled - cameraButton.isUserInteractionEnabled = isUserInteractionEnabled - mainButton.isUserInteractionEnabled = isUserInteractionEnabled - } - } - - // 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 = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self, hasOpaqueBackground: true) - result.accessibilityIdentifier = "GIF button" - result.isAccessibilityElement = true - return result - }() - lazy var gifButtonContainer = container(for: gifButton) - lazy var documentButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self, hasOpaqueBackground: true) - result.accessibilityIdentifier = "Documents folder" - result.accessibilityLabel = "Files" - result.isAccessibilityElement = true - - return result - }() - lazy var documentButtonContainer = container(for: documentButton) - lazy var libraryButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self, hasOpaqueBackground: true) - result.accessibilityIdentifier = "Images folder" - result.accessibilityLabel = "Photo library" - result.isAccessibilityElement = true - - return result - }() - lazy var libraryButtonContainer = container(for: libraryButton) - lazy var cameraButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), delegate: self, hasOpaqueBackground: true) - result.accessibilityIdentifier = "Select camera button" - result.accessibilityLabel = "Camera" - result.isAccessibilityElement = true - - return result - }() - lazy var cameraButtonContainer = container(for: cameraButton) - lazy var mainButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "ic_plus_24"), delegate: self) - result.accessibilityLabel = "Add attachment" - - return result - }() - 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 { - mainButton.accessibilityLabel = "Collapse attachment options" - 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 { - mainButton.accessibilityLabel = "Add attachment" - [ 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) { - guard !isSoftDisabled else { - delegate?.handleDisabledAttachmentButtonTapped() - return - } - - 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 } - } - - func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {} - func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {} - func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) {} - - // MARK: Convenience - private func container(for button: InputViewButton) -> UIView { - let result = UIView() - result.isAccessibilityElement = true - 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: AnyObject { - func handleDisabledAttachmentButtonTapped() - - func handleGIFButtonTapped() - func handleDocumentButtonTapped() - func handleLibraryButtonTapped() - func handleCameraButtonTapped() -} diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 953dd97f4d..eb9c5fc4e2 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -62,71 +62,28 @@ public extension LinkPreview { } } - // MARK: SentState + // MARK: - SentState - struct SentState: LinkPreviewState { - var isLoaded: Bool { true } - var urlString: String? { linkPreview.url } - - var title: String? { - guard let value = linkPreview.title, value.count > 0 else { return nil } - - return value - } - - var imageState: LinkPreview.ImageState { - guard linkPreview.attachmentId != nil else { return .none } - guard let imageAttachment: Attachment = imageAttachment else { - Log.error("[LinkPreview] Missing imageAttachment.") - return .none - } - - switch imageAttachment.state { - case .downloaded, .uploaded: - guard imageAttachment.isImage && imageAttachment.isValid else { - return .invalid - } - - return .loaded - - case .pendingDownload, .downloading, .uploading: return .loading - case .failedDownload, .failedUpload, .invalid: return .invalid - } - } - - var imageSource: ImageDataManager.DataSource? { - // Note: We don't check if the image is valid here because that can be confirmed - // in 'imageState' and it's a little inefficient - guard - imageAttachment?.isImage == true, - let imageDownloadUrl: String = imageAttachment?.downloadUrl, - let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: imageDownloadUrl) - else { return nil } - - return .url(URL(fileURLWithPath: path)) - } - - // MARK: - Type Specific - - private let dependencies: Dependencies - private let linkPreview: LinkPreview - private let imageAttachment: Attachment? - - public var imageSize: CGSize { - guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else { - return CGSize.zero - } - - return CGSize(width: CGFloat(width), height: CGFloat(height)) - } - - // MARK: - Initialization - - init(linkPreview: LinkPreview, imageAttachment: Attachment?, using dependencies: Dependencies) { - self.dependencies = dependencies - self.linkPreview = linkPreview - self.imageAttachment = imageAttachment - } + func sentState( + imageAttachment: Attachment?, + using dependencies: Dependencies + ) -> LinkPreviewViewModel { + return LinkPreviewViewModel( + state: .sent, + urlString: url, + title: (title?.isEmpty == false ? title : nil), + imageSource: { + /// **Note:** We don't check if the image is valid here because that can be confirmed in 'imageState' and it's a + /// little inefficient + guard + imageAttachment?.isImage == true, + let imageDownloadUrl: String = imageAttachment?.downloadUrl, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: imageDownloadUrl) + else { return nil } + + return .url(URL(fileURLWithPath: path)) + }() + ) } } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift deleted file mode 100644 index adb0ce5294..0000000000 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import NVActivityIndicatorView -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit - -final class LinkPreviewView: UIView { - private static let loaderSize: CGFloat = 24 - private static let cancelButtonSize: CGFloat = 45 - - private let dependencies: Dependencies - private let maxWidth: CGFloat - private let onCancel: (() -> ())? - - // MARK: - UI - - private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100) - private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100) - - // MARK: UI Components - - public var previewView: UIView { hStackView } - - private lazy var imageView: SessionImageView = { - let result: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) - result.contentMode = .scaleAspectFill - - return result - }() - - private lazy var imageViewContainer: UIView = { - let result: UIView = UIView() - result.clipsToBounds = true - - return result - }() - - private let loader: NVActivityIndicatorView = { - let result: NVActivityIndicatorView = NVActivityIndicatorView( - frame: CGRect.zero, - type: .circleStrokeSpin, - color: .black, - padding: nil - ) - - ThemeManager.onThemeChange(observer: result) { [weak result] _, _, resolve in - guard let textPrimary: UIColor = resolve(.textPrimary) else { return } - - result?.color = textPrimary - } - - return result - }() - - private lazy var titleLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.smallFontSize) - result.numberOfLines = 0 - - return result - }() - - private lazy var bodyTappableLabelContainer: UIView = UIView() - - private lazy var hStackViewContainer: UIView = UIView() - - private lazy var hStackView: UIStackView = UIStackView() - - private lazy var cancelButton: UIButton = { - let result: UIButton = UIButton(type: .custom) - result.setImage( - UIImage(named: "X")? - .withRenderingMode(.alwaysTemplate), - for: .normal - ) - result.themeTintColor = .textPrimary - - 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 - }() - - var bodyTappableLabel: TappableLabel? - - // MARK: - Initialization - - init( - maxWidth: CGFloat, - using dependencies: Dependencies, - onCancel: (() -> ())? = nil - ) { - self.dependencies = dependencies - self.maxWidth = maxWidth - self.onCancel = onCancel - - 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.mediumSpacing) - - // 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, bodyTappableLabelContainer ]) - 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 - - @MainActor public func update( - with state: LinkPreviewState, - isOutgoing: Bool, - delegate: TappableLabelDelegate? = nil, - cellViewModel: MessageViewModel? = nil, - bodyLabelTextColor: ThemeValue? = nil, - lastSearchText: String? = nil, - using dependencies: Dependencies - ) { - cancelButton.removeFromSuperview() - - switch state { - case is LinkPreview.LoadingState: - loader.alpha = 1 - loader.startAnimating() - imageView.image = nil - - case is LinkPreview.DraftState, is LinkPreview.SentState: - let imageContentExists: Bool = (state.imageSource?.contentExists == true) - let imageSource: ImageDataManager.DataSource = { - guard - let source: ImageDataManager.DataSource = state.imageSource, - source.contentExists - else { return .icon(.link, size: 32, renderingMode: .alwaysTemplate) } - - return source - }() - loader.alpha = 0 - loader.stopAnimating() - imageView.loadImage(imageSource) - imageView.contentMode = (imageContentExists ? .scaleAspectFill : .center) - - default: - loader.alpha = 0 - loader.stopAnimating() - imageView.image = nil - } - - // Image view - let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 100 : 80) - imageViewContainerWidthConstraint.constant = imageViewContainerSize - imageViewContainerHeightConstraint.constant = imageViewContainerSize - imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8) - imageView.themeTintColor = (isOutgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - - // Title - titleLabel.text = state.title - titleLabel.themeTextColor = (isOutgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - - // Horizontal stack view - switch state { - case is LinkPreview.LoadingState: - imageViewContainer.themeBackgroundColor = .clear - hStackViewContainer.themeBackgroundColor = nil - - case is LinkPreview.SentState: - imageViewContainer.themeBackgroundColor = .messageBubble_overlay - hStackViewContainer.themeBackgroundColor = .messageBubble_overlay - - default: - imageViewContainer.themeBackgroundColor = .messageBubble_overlay - hStackViewContainer.themeBackgroundColor = nil - } - - // Body text view - bodyTappableLabelContainer.subviews.forEach { $0.removeFromSuperview() } - - if let cellViewModel: MessageViewModel = cellViewModel { - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( - for: cellViewModel, - with: maxWidth, - textColor: (bodyLabelTextColor ?? .textPrimary), - searchText: lastSearchText, - delegate: delegate, - using: dependencies - ).label - - self.bodyTappableLabel = bodyTappableLabel - bodyTappableLabelContainer.addSubview(bodyTappableLabel) - bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12) - } - - if state is LinkPreview.DraftState { - hStackView.addArrangedSubview(cancelButton) - } - } - - // MARK: - Interaction - - @objc private func cancel() { - onCancel?() - } -} diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift index 270133b68f..2c12b12fa8 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift @@ -6,7 +6,7 @@ import SessionUIKit import SessionMessagingKit public struct LinkPreviewView_SwiftUI: View { - private var state: LinkPreviewState + private var viewModel: LinkPreviewViewModel private var dataManager: ImageDataManagerType private var isOutgoing: Bool private let maxWidth: CGFloat @@ -19,7 +19,7 @@ public struct LinkPreviewView_SwiftUI: View { private static let cancelButtonSize: CGFloat = 45 init( - state: LinkPreviewState, + viewModel: LinkPreviewViewModel, dataManager: ImageDataManagerType, isOutgoing: Bool, maxWidth: CGFloat = .infinity, @@ -28,7 +28,7 @@ public struct LinkPreviewView_SwiftUI: View { lastSearchText: String? = nil, onCancel: (() -> ())? = nil ) { - self.state = state + self.viewModel = viewModel self.dataManager = dataManager self.isOutgoing = isOutgoing self.maxWidth = maxWidth @@ -42,7 +42,7 @@ public struct LinkPreviewView_SwiftUI: View { ZStack( alignment: .leading ) { - if state is LinkPreview.SentState { + if viewModel.state == .sent { ThemeColor(.messageBubble_overlay).ignoresSafeArea() } @@ -51,8 +51,8 @@ public struct LinkPreviewView_SwiftUI: View { spacing: Values.mediumSpacing ) { // Link preview image - let imageSize: CGFloat = state is LinkPreview.SentState ? 100 : 80 - if let linkPreviewImageSource: ImageDataManager.DataSource = state.imageSource { + let imageSize: CGFloat = (viewModel.state == .sent ? 100 : 80) + if let linkPreviewImageSource: ImageDataManager.DataSource = viewModel.imageSource { SessionAsyncImage( source: linkPreviewImageSource, dataManager: dataManager, @@ -69,7 +69,7 @@ public struct LinkPreviewView_SwiftUI: View { width: imageSize, height: imageSize ) - .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + .cornerRadius(viewModel.state == .sent ? 0 : 8) }, placeholder: { ThemeColor(.alert_background) @@ -77,10 +77,10 @@ public struct LinkPreviewView_SwiftUI: View { width: imageSize, height: imageSize ) - .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + .cornerRadius(viewModel.state == .sent ? 0 : 8) } ) - } else if state is LinkPreview.DraftState || state is LinkPreview.SentState { + } else if viewModel.state == .draft || viewModel.state == .sent { LucideIcon(.link, size: IconSize.medium.size) .foregroundColor( themeColor: isOutgoing ? @@ -92,7 +92,7 @@ public struct LinkPreviewView_SwiftUI: View { height: imageSize ) .backgroundColor(themeColor: .messageBubble_overlay) - .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + .cornerRadius(viewModel.state == .sent ? 0 : 8) } else { ActivityIndicator(themeColor: .borderSeparator, width: 2) .frame( @@ -102,7 +102,7 @@ public struct LinkPreviewView_SwiftUI: View { } // Link preview title - if let title: String = state.title { + if let title: String = viewModel.title { Text(title) .bold() .font(.system(size: Values.smallFontSize)) @@ -117,7 +117,7 @@ public struct LinkPreviewView_SwiftUI: View { } // Cancel button - if state is LinkPreview.DraftState { + if viewModel.state == .draft { Spacer(minLength: 0) Button(action: { @@ -142,12 +142,11 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { static var previews: some View { VStack { LinkPreviewView_SwiftUI( - state: LinkPreview.DraftState( - linkPreviewDraft: .init( - urlString: "https://github.com/oxen-io", - title: "Github - oxen-io/session-ios: A private messenger for iOS.", - imageSource: .image("AppIcon", UIImage(named: "AppIcon")) - ) + viewModel: LinkPreviewViewModel( + state: .draft, + urlString: "https://github.com/oxen-io", + title: "Github - oxen-io/session-ios: A private messenger for iOS.", + imageSource: .image("AppIcon", UIImage(named: "AppIcon")) ), dataManager: ImageDataManager(), isOutgoing: true @@ -155,7 +154,10 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { .padding(.horizontal, Values.mediumSpacing) LinkPreviewView_SwiftUI( - state: LinkPreview.LoadingState(), + viewModel: LinkPreviewViewModel( + state: .loading, + urlString: "https://github.com/oxen-io" + ), dataManager: ImageDataManager(), isOutgoing: true ) diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift deleted file mode 100644 index 9f92551551..0000000000 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import SwiftUI -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit - -struct QuoteView_SwiftUI: View { - public enum Mode { case regular, draft } - public enum Direction { case incoming, outgoing } - public struct Info { - var mode: Mode - var authorId: String - var quotedText: String? - var threadVariant: SessionThread.Variant - var currentUserSessionIds: Set - var direction: Direction - var attachment: Attachment? - } - - private static let thumbnailSize: CGFloat = 48 - private static let iconSize: CGFloat = 24 - private static let labelStackViewSpacing: CGFloat = 2 - private static let labelStackViewVMargin: CGFloat = 4 - private static let cancelButtonSize: CGFloat = 33 - private static let cornerRadius: CGFloat = 4 - - private let dependencies: Dependencies - private var info: Info - private var onCancel: (() -> ())? - - private var isCurrentUser: Bool { info.currentUserSessionIds.contains(info.authorId) } - private var quotedText: String? { - if let quotedText = info.quotedText, !quotedText.isEmpty { - return quotedText - } - - if let attachment = info.attachment { - return attachment.shortDescription - } - - return nil - } - private var author: String? { - guard !isCurrentUser else { return "you".localized() } - guard quotedText != nil else { - // When we can't find the quoted message we want to hide the author label - return Profile.displayNameNoFallback( - id: info.authorId, - threadVariant: info.threadVariant, - using: dependencies - ) - } - - return Profile.displayName( - id: info.authorId, - threadVariant: info.threadVariant, - using: dependencies - ) - } - - public init(info: Info, using dependencies: Dependencies, onCancel: (() -> ())? = nil) { - self.dependencies = dependencies - self.info = info - self.onCancel = onCancel - } - - var body: some View { - HStack( - alignment: .center, - spacing: Values.smallSpacing - ) { - if let attachment: Attachment = info.attachment { - ZStack() { - RoundedRectangle( - cornerRadius: Self.cornerRadius - ) - .fill(themeColor: .messageBubble_overlay) - .frame( - width: Self.thumbnailSize, - height: Self.thumbnailSize - ) - - SessionAsyncImage( - attachment: attachment, - thumbnailSize: .small, - using: dependencies - ) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - let fallbackImageName: String = (attachment.isAudio ? "attachment_audio" : "actionsheet_document_black") - - if let image = UIImage(named: fallbackImageName)?.withRenderingMode(.alwaysTemplate) { - Image(uiImage: image) - .foregroundColor(themeColor: { - switch info.mode { - case .regular: return (info.direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - case .draft: return .textPrimary - } - }()) - } - else { - Color.clear - } - } - .frame( - width: Self.iconSize, - height: Self.iconSize, - alignment: .center - ) - } - } else { - // Line view - let lineColor: ThemeValue = { - switch info.mode { - case .regular: return (info.direction == .outgoing ? .messageBubble_outgoingText : .primary) - case .draft: return .primary - } - }() - - Rectangle() - .foregroundColor(themeColor: lineColor) - .frame(width: Values.accentLineThickness) - } - - // Quoted text and author - VStack( - alignment: .leading, - spacing: Self.labelStackViewSpacing - ) { - let targetThemeColor: ThemeValue = { - switch info.mode { - case .regular: return (info.direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - case .draft: return .textPrimary - } - }() - - if let author = self.author { - Text(author) - .bold() - .font(.system(size: Values.smallFontSize)) - .foregroundColor(themeColor: targetThemeColor) - } - - if let quotedText = self.quotedText { - AttributedText( - MentionUtilities.highlightMentions( - in: quotedText, - threadVariant: info.threadVariant, - currentUserSessionIds: info.currentUserSessionIds, - location: { - switch (info.mode, info.direction) { - case (.draft, _): return .quoteDraft - case (_, .outgoing): return .outgoingQuote - case (_, .incoming): return .incomingQuote - } - }(), - textColor: targetThemeColor, - attributes: [ - .themeForegroundColor: targetThemeColor, - .font: UIFont.systemFont(ofSize: Values.smallFontSize) - ], - using: dependencies - ) - ) - .lineLimit(2) - } else { - Text("messageErrorOriginal".localized()) - .font(.system(size: Values.smallFontSize)) - .foregroundColor(themeColor: targetThemeColor) - } - } - .padding(.vertical, Self.labelStackViewVMargin) - - if info.mode == .draft { - // Cancel button - Button( - action: { - onCancel?() - }, - label: { - if let image = UIImage(named: "X")?.withRenderingMode(.alwaysTemplate) { - Image(uiImage: image) - .foregroundColor(themeColor: .textPrimary) - .frame( - width: Self.cancelButtonSize, - height: Self.cancelButtonSize, - alignment: .center - ) - } - } - ) - } - } - .padding(.trailing, Values.smallSpacing) - } -} - -struct QuoteView_SwiftUI_Previews: PreviewProvider { - static var previews: some View { - ZStack { - ThemeColor(.backgroundPrimary).ignoresSafeArea() - VStack(spacing: 20) { - QuoteView_SwiftUI( - info: QuoteView_SwiftUI.Info( - mode: .draft, - authorId: "", - threadVariant: .contact, - currentUserSessionIds: [], - direction: .outgoing - ), - using: Dependencies.createEmpty() - ) - .frame(height: 40) - - QuoteView_SwiftUI( - info: QuoteView_SwiftUI.Info( - mode: .regular, - authorId: "", - threadVariant: .contact, - currentUserSessionIds: [], - direction: .incoming, - attachment: Attachment( - variant: .standard, - state: .downloaded, - contentType: "audio/wav", - byteCount: 0 - ) - ), - using: Dependencies.createEmpty() - ) - .previewLayout(.sizeThatFits) - } - } - } -} diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index b6b64b2ef3..919f6c5d1a 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -254,7 +254,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { static let contactThreadHSpacing = Values.mediumSpacing static var gutterSize: CGFloat = { - var result = groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing + var result = groupThreadHSpacing + ProfilePictureView.Info.Size.message.viewSize + groupThreadHSpacing if UIDevice.current.isIPad { result += 168 @@ -263,7 +263,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return result }() - static var leftGutterSize: CGFloat { groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing } + static var leftGutterSize: CGFloat { groupThreadHSpacing + ProfilePictureView.Info.Size.message.viewSize + groupThreadHSpacing } // MARK: Direction & Position @@ -549,28 +549,38 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: - let linkPreviewView: LinkPreviewView = LinkPreviewView( - maxWidth: maxWidth, - using: dependencies - ) + let stackView: UIStackView = UIStackView() + stackView.axis = .vertical + bubbleView.addSubview(stackView) + stackView.pin(to: bubbleView) + snContentView.addArrangedSubview(bubbleBackgroundView) + + let linkPreviewView: LinkPreviewView = LinkPreviewView() linkPreviewView.update( - with: LinkPreview.SentState( - linkPreview: linkPreview, + with: linkPreview.sentState( imageAttachment: cellViewModel.linkPreviewAttachment, using: dependencies ), isOutgoing: cellViewModel.variant.isOutgoing, - delegate: self, - cellViewModel: cellViewModel, - bodyLabelTextColor: bodyLabelTextColor, - lastSearchText: lastSearchText, - using: dependencies + dataManager: dependencies[singleton: .imageDataManager] ) + stackView.addArrangedSubview(linkPreviewView) self.linkPreviewView = linkPreviewView - bubbleView.addSubview(linkPreviewView) - linkPreviewView.pin(to: bubbleView, withInset: 0) - snContentView.addArrangedSubview(bubbleBackgroundView) - self.bodyTappableLabel = linkPreviewView.bodyTappableLabel + + let bodyTappableLabelContainer: UIView = UIView() + let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self, + using: dependencies + ).label + + bodyTappableLabelContainer.addSubview(bodyTappableLabel) + bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12) + stackView.addArrangedSubview(bodyTappableLabelContainer) + self.bodyTappableLabel = bodyTappableLabel case .openGroupInvitation: let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( @@ -596,17 +606,20 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { stackView.setCompressionResistance(.vertical, to: .required) // Quote view - if let quotedInfo: MessageViewModel.QuotedInfo = cellViewModel.quotedInfo { + if let quoteViewModel: QuoteViewModel = cellViewModel.quoteViewModel { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( - for: .regular, - authorId: quotedInfo.authorId, - quotedText: quotedInfo.body, - threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: quotedInfo.attachment, - using: dependencies + viewModel: quoteViewModel.with( + thumbnailSource: .thumbnailFrom( + quoteViewModel: quoteViewModel, + using: dependencies + ), + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: cellViewModel.threadVariant, + using: dependencies + ) + ), + dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) @@ -679,9 +692,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) - 2 * inset ) - switch (cellViewModel.quotedInfo, cellViewModel.body) { + switch (cellViewModel.quoteViewModel, cellViewModel.body) { /// Both quote and body - case (.some(let quotedInfo), .some(let body)) where !body.isEmpty: + case (.some(let quoteViewModel), .some(let body)) where !body.isEmpty: // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical @@ -690,14 +703,17 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // Quote view let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( - for: .regular, - authorId: quotedInfo.authorId, - quotedText: quotedInfo.body, - threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: quotedInfo.attachment, - using: dependencies + viewModel: quoteViewModel.with( + thumbnailSource: .thumbnailFrom( + quoteViewModel: quoteViewModel, + using: dependencies + ), + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: cellViewModel.threadVariant, + using: dependencies + ) + ), + dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) @@ -767,16 +783,19 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { snContentView.addArrangedSubview(bubbleBackgroundView) /// Just quote - case (.some(let quotedInfo), _): + case (.some(let quoteViewModel), _): let quoteView: QuoteView = QuoteView( - for: .regular, - authorId: quotedInfo.authorId, - quotedText: quotedInfo.body, - threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: quotedInfo.attachment, - using: dependencies + viewModel: quoteViewModel.with( + thumbnailSource: .thumbnailFrom( + quoteViewModel: quoteViewModel, + using: dependencies + ), + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: cellViewModel.threadVariant, + using: dependencies + ) + ), + dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -1261,7 +1280,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { cellViewModel.canHaveProfile else { return 0 } - return ProfilePictureView.Size.message.viewSize + groupThreadHSpacing + return ProfilePictureView.Info.Size.message.viewSize + groupThreadHSpacing }() let oppositeEdgePadding: CGFloat = (includingOppositeGutter ? gutterSize : contactThreadHSpacing) diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/AfterLayoutCallbackTableView.swift similarity index 56% rename from Session/Conversations/Views & Modals/InsetLockableTableView.swift rename to Session/Conversations/Views & Modals/AfterLayoutCallbackTableView.swift index 1f0fb79803..f31e482268 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/AfterLayoutCallbackTableView.swift @@ -2,30 +2,12 @@ import UIKit -/// This custom UITableView gives us two convenience behaviours: -/// -/// 1. It allows us to lock the contentOffset to a specific value - it's currently used to prevent the ConversationVC first -/// responder resignation from making the MediaGalleryDetailViewController transition from looking buggy (ie. the table -/// scrolls down with the resignation during the transition) -/// -/// 2. It allows us to provode a callback which gets triggered if a condition closure returns true - it's currently used to prevent -/// the table view from jumping when inserting new pages at the top of a conversation screen -public class InsetLockableTableView: UITableView { - public var lockContentOffset: Bool = false { - didSet { - guard !lockContentOffset else { return } - - self.contentOffset = newOffset - } - } - public var oldOffset: CGPoint = .zero - public var newOffset: CGPoint = .zero +public class AfterLayoutCallbackTableView: UITableView { private var callbackCondition: ((Int, [Int], CGSize) -> Bool)? private var afterLayoutSubviewsCallback: (() -> ())? + private var lastAdjustedInset: UIEdgeInsets = .zero public override func layoutSubviews() { - self.newOffset = self.contentOffset - // Store the callback locally to prevent infinite loops var callback: (() -> ())? @@ -34,21 +16,20 @@ public class InsetLockableTableView: UITableView { self.afterLayoutSubviewsCallback = nil } - guard !lockContentOffset else { - self.contentOffset = CGPoint( - x: newOffset.x, - y: oldOffset.y - ) - - super.layoutSubviews() - callback?() - return - } - super.layoutSubviews() callback?() + } + + public override func adjustedContentInsetDidChange() { + super.adjustedContentInsetDidChange() + + let insetDifference: CGFloat = adjustedContentInset.bottom - lastAdjustedInset.bottom + + if insetDifference > 0 { + contentOffset.y += insetDifference + } - self.oldOffset = self.contentOffset + lastAdjustedInset = adjustedContentInset } // MARK: - Functions diff --git a/Session/Home/Message Requests/Views/MessageRequestsCell.swift b/Session/Home/Message Requests/Views/MessageRequestsCell.swift index 768fe820f0..474760ddf6 100644 --- a/Session/Home/Message Requests/Views/MessageRequestsCell.swift +++ b/Session/Home/Message Requests/Views/MessageRequestsCell.swift @@ -29,7 +29,7 @@ class MessageRequestsCell: UITableViewCell { result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.themeBackgroundColor = .conversationButton_unreadBubbleBackground - result.layer.cornerRadius = (ProfilePictureView.Size.list.viewSize / 2) + result.layer.cornerRadius = (ProfilePictureView.Info.Size.list.viewSize / 2) return result }() @@ -107,8 +107,8 @@ class MessageRequestsCell: UITableViewCell { constant: (Values.accentLineThickness + Values.mediumSpacing) ), iconContainerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - iconContainerView.widthAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize), - iconContainerView.heightAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize), + iconContainerView.widthAnchor.constraint(equalToConstant: ProfilePictureView.Info.Size.list.viewSize), + iconContainerView.heightAnchor.constraint(equalToConstant: ProfilePictureView.Info.Size.list.viewSize), iconLabel.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor), iconLabel.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor), diff --git a/Session/Media Viewing & Editing/AllMediaViewController.swift b/Session/Media Viewing & Editing/AllMediaViewController.swift index 1b6f32d42c..6def4b1f88 100644 --- a/Session/Media Viewing & Editing/AllMediaViewController.swift +++ b/Session/Media Viewing & Editing/AllMediaViewController.swift @@ -260,9 +260,9 @@ extension AllMediaViewController: MediaPresentationContextProvider { func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { return self.mediaTitleViewController.mediaPresentationContext(mediaId: mediaId, in: coordinateSpace) } - - func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { - return self.mediaTitleViewController.snapshotOverlayView(in: coordinateSpace) + + func lowestViewToRenderAboveContent() -> UIView? { + return nil } } diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 0405f6f052..350a870b11 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -118,7 +118,7 @@ public class MediaGalleryViewModel { ) // Run the initial query on a backgorund thread so we don't block the push transition - let loadInitialData: () -> () = { [weak self] in + let loadInitialData: (@escaping () -> ()) -> () = { [weak self] onComplete in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) guard let initialFocusedId: String = focusedAttachmentId else { @@ -126,7 +126,7 @@ public class MediaGalleryViewModel { return } - self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId)) + self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId), onComplete: onComplete) } // We have a custom transition when going from an attachment detail screen to the tile gallery @@ -134,13 +134,17 @@ public class MediaGalleryViewModel { // to do the transition (we don't clear the 'unobservedGalleryDataChanges' after setting it as // we don't want to mess with the initial view controller behaviour) guard !performInitialQuerySync else { - loadInitialData() + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + loadInitialData { + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + .milliseconds(100)) updateGalleryData(self.unobservedGalleryDataChanges ?? []) return } DispatchQueue.global(qos: .userInitiated).async { - loadInitialData() + loadInitialData({}) } } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 980bd3e7a0..257a1ba143 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -204,6 +204,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + /// Apply the nav styling in `viewWillAppear` instead of `viewDidLoad` as it's possible the nav stack isn't fully setup + /// and could crash when trying to access it (whereas by the time `viewWillAppear` is called it should be setup) + ThemeManager.applyNavigationStylingIfNeeded(to: self) startObservingChanges() } @@ -960,8 +963,8 @@ extension MediaPageViewController: MediaPresentationContextProvider { cornerMask: CACornerMask() ) } - - func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { - return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + + func lowestViewToRenderAboveContent() -> UIView? { + return nil } } diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index a5cc997b8c..55d347cf19 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -959,9 +959,9 @@ extension MediaTileViewController: MediaPresentationContextProvider { cornerMask: CACornerMask() ) } - - func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { - return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + + func lowestViewToRenderAboveContent() -> UIView? { + return nil } } diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 76cdb79c27..b7d486c92e 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -281,7 +281,7 @@ struct MessageInfoScreen: View { HStack( spacing: 10 ) { - let (info, additionalInfo) = ProfilePictureView.getProfilePictureInfo( + let (info, additionalInfo) = ProfilePictureView.Info.generateInfoFrom( size: .message, publicKey: ( // Prioritise the profile.id because we override it for @@ -296,7 +296,7 @@ struct MessageInfoScreen: View { using: dependencies ) - let size: ProfilePictureView.Size = .list + let size: ProfilePictureView.Info.Size = .list if let info: ProfilePictureView.Info = info { ProfilePictureSwiftUI( @@ -473,8 +473,7 @@ struct MessageBubble: View { switch linkPreview.variant { case .standard: LinkPreviewView_SwiftUI( - state: LinkPreview.SentState( - linkPreview: linkPreview, + viewModel: linkPreview.sentState( imageAttachment: messageViewModel.linkPreviewAttachment, using: dependencies ), @@ -495,18 +494,19 @@ struct MessageBubble: View { } } else { - if let quotedInfo: MessageViewModel.QuotedInfo = messageViewModel.quotedInfo { + if let quoteViewModel: QuoteViewModel = messageViewModel.quoteViewModel { QuoteView_SwiftUI( - info: .init( - mode: .regular, - authorId: quotedInfo.authorId, - quotedText: quotedInfo.body, - threadVariant: messageViewModel.threadVariant, - currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), - direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - attachment: quotedInfo.attachment + viewModel: quoteViewModel.with( + thumbnailSource: .thumbnailFrom( + quoteViewModel: quoteViewModel, + using: dependencies + ), + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: messageViewModel.threadVariant, + using: dependencies + ) ), - using: dependencies + dataManager: dependencies[singleton: .imageDataManager] ) .fixedSize(horizontal: false, vertical: true) .padding(.top, Self.inset) @@ -633,7 +633,7 @@ struct MessageInfoView_Previews: PreviewProvider { id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestUser" ), - quotedInfo: nil, + quoteViewModel: nil, linkPreview: nil, linkPreviewAttachment: nil, attachments: nil diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 89663e8702..11627b95f3 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -20,15 +20,22 @@ class SendMediaNavigationController: UINavigationController { private let dependencies: Dependencies private let threadId: String private let threadVariant: SessionThread.Variant + private var quoteDraft: QuoteViewModel? private var disposables: Set = Set() private var loadMediaTask: Task? // MARK: - Initialization - init(threadId: String, threadVariant: SessionThread.Variant, using dependencies: Dependencies) { + init( + threadId: String, + threadVariant: SessionThread.Variant, + quoteDraft: QuoteViewModel?, + using dependencies: Dependencies + ) { self.dependencies = dependencies self.threadId = threadId self.threadVariant = threadVariant + self.quoteDraft = quoteDraft super.init(nibName: nil, bundle: nil) } @@ -83,15 +90,35 @@ class SendMediaNavigationController: UINavigationController { public weak var sendMediaNavDelegate: SendMediaNavDelegate? - public class func showingCameraFirst(threadId: String, threadVariant: SessionThread.Variant, using dependencies: Dependencies) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(threadId: threadId, threadVariant: threadVariant, using: dependencies) + public class func showingCameraFirst( + threadId: String, + threadVariant: SessionThread.Variant, + quoteDraft: QuoteViewModel?, + using dependencies: Dependencies + ) -> SendMediaNavigationController { + let navController: SendMediaNavigationController = SendMediaNavigationController( + threadId: threadId, + threadVariant: threadVariant, + quoteDraft: quoteDraft, + using: dependencies + ) navController.viewControllers = [navController.captureViewController] return navController } - public class func showingMediaLibraryFirst(threadId: String, threadVariant: SessionThread.Variant, using dependencies: Dependencies) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(threadId: threadId, threadVariant: threadVariant, using: dependencies) + public class func showingMediaLibraryFirst( + threadId: String, + threadVariant: SessionThread.Variant, + quoteDraft: QuoteViewModel?, + using dependencies: Dependencies + ) -> SendMediaNavigationController { + let navController: SendMediaNavigationController = SendMediaNavigationController( + threadId: threadId, + threadVariant: threadVariant, + quoteDraft: quoteDraft, + using: dependencies + ) navController.viewControllers = [navController.mediaLibraryViewController] return navController @@ -244,6 +271,7 @@ class SendMediaNavigationController: UINavigationController { threadId: self.threadId, threadVariant: self.threadVariant, attachments: self.attachments, + quoteDraft: self.quoteDraft, disableLinkPreviewImageDownload: false, didLoadLinkPreview: nil, using: dependencies diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift index 3e388d7609..1cca9fd2a5 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -5,22 +5,17 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -class MediaDismissAnimationController: NSObject { - private let dependencies: Dependencies - private let attachment: Attachment +class MediaDismissAnimationController: MediaAnimationController { public let interactionController: MediaInteractiveDismiss? var fromView: UIView? - var transitionView: UIView? - var fromTransitionalOverlayView: UIView? - var toTransitionalOverlayView: UIView? var fromMediaFrame: CGRect? var pendingCompletion: (() -> ())? init(attachment: Attachment, interactionController: MediaInteractiveDismiss? = nil, using dependencies: Dependencies) { - self.dependencies = dependencies - self.attachment = attachment self.interactionController = interactionController + + super.init(attachment: attachment, using: dependencies) } } @@ -31,71 +26,20 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let containerView = transitionContext.containerView - let fromContextProvider: MediaPresentationContextProvider - let toContextProvider: MediaPresentationContextProvider guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from), - let toVC: UIViewController = transitionContext.viewController(forKey: .to) + let toVC: UIViewController = transitionContext.viewController(forKey: .to), + let fromContextProvider: MediaPresentationContextProvider = extractContextProvider(from: fromVC), + let toContextProvider: MediaPresentationContextProvider = extractContextProvider(from: toVC) else { return fallbackTransition(context: transitionContext) } - switch fromVC { - case let contextProvider as MediaPresentationContextProvider: - fromContextProvider = contextProvider - - case let topBannerController as TopBannerController: - guard - let firstChild: UIViewController = topBannerController.children.first, - let navController: UINavigationController = firstChild as? UINavigationController, - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(context: transitionContext) } - - fromContextProvider = contextProvider - - case let navController as UINavigationController: - guard - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(context: transitionContext) } - - fromContextProvider = contextProvider - - default: return fallbackTransition(context: transitionContext) - } - - switch toVC { - case let contextProvider as MediaPresentationContextProvider: - toVC.view.layoutIfNeeded() - toContextProvider = contextProvider - - case let topBannerController as TopBannerController: - guard - let firstChild: UIViewController = topBannerController.children.first, - let navController: UINavigationController = firstChild as? UINavigationController, - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(context: transitionContext) } - - toVC.view.layoutIfNeeded() - toContextProvider = contextProvider - - case let navController as UINavigationController: - guard - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(context: transitionContext) } - - toVC.view.layoutIfNeeded() - toContextProvider = contextProvider - - default: return fallbackTransition(context: transitionContext) - } + toVC.view.layoutIfNeeded() guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext( mediaId: attachment.id, in: containerView - ), - let presentationSource: ImageDataManager.DataSource = ImageDataManager.DataSource.from( - attachment: attachment, - using: dependencies ) else { return fallbackTransition(context: transitionContext) } @@ -104,6 +48,20 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning if let fromView: UIView = transitionContext.view(forKey: .from) { self.fromView = fromView containerView.addSubview(fromView) + + let navBarView: UIView? = fromView.subviews.first(where: { $0 is UINavigationBar }) + createAndAddMask( + to: fromView, + holeFrame: fromMediaContext.presentationFrame, + cornerRadius: fromMediaContext.cornerRadius, + in: containerView, + viewport: CGRect( + x: 0, + y: (navBarView?.frame.maxY ?? 0), + width: fromView.bounds.width, + height: (fromView.bounds.height - (navBarView?.frame.maxY ?? 0)) + ) + ) } // toView will be nil if doing a modal dismiss, in which case we don't want to add the view - @@ -121,85 +79,60 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning let transitionView: SessionImageView = SessionImageView( dataManager: dependencies[singleton: .imageDataManager] ) - transitionView.loadImage(presentationSource) + transitionView.copyContentAndAnimationPoint(from: fromMediaContext.mediaView) transitionView.frame = fromMediaContext.presentationFrame transitionView.contentMode = MediaView.contentMode transitionView.layer.masksToBounds = true transitionView.layer.cornerRadius = fromMediaContext.cornerRadius transitionView.layer.maskedCorners = (toMediaContext?.cornerMask ?? fromMediaContext.cornerMask) - containerView.addSubview(transitionView) - - // Set the currently loaded image to prevent any odd delay and try to match the animation - // state to the source - transitionView.image = fromMediaContext.mediaView.image + insertTransitionView( + transitionView, + intoView: toVC.view, + contextProvider: toContextProvider, + startFrame: fromMediaContext.presentationFrame, + containerView: containerView + ) - if fromMediaContext.mediaView.isAnimating { - transitionView.startAnimationLoop() - transitionView.setAnimationPoint( - index: fromMediaContext.mediaView.currentFrameIndex, - time: fromMediaContext.mediaView.accumulatedTime - ) - } - - // Add any UI elements which should appear above the media view - self.fromTransitionalOverlayView = { - guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else { - return nil - } - - overlayView.frame = overlayViewFrame - containerView.addSubview(overlayView) - - return overlayView - }() - self.toTransitionalOverlayView = { [weak self] in - guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else { - return nil - } - - // Only fade in the 'toTransitionalOverlayView' if it's bigger than the origin - // one (makes it look cleaner as you don't get the crossfade effect) - if (self?.fromTransitionalOverlayView?.frame.size.height ?? 0) > overlayViewFrame.height { - overlayView.alpha = 0 - } - - overlayView.frame = overlayViewFrame - - if let fromTransitionalOverlayView = self?.fromTransitionalOverlayView { - containerView.insertSubview(overlayView, belowSubview: fromTransitionalOverlayView) - } - else { - containerView.addSubview(overlayView) - } - - return overlayView - }() - - self.transitionView = transitionView self.fromMediaFrame = transitionView.frame + + // Start display link to update mask during animation + startDisplayLink() self.pendingCompletion = { let destinationFromAlpha: CGFloat let destinationFrame: CGRect + let destinationFrameInContainer: CGRect let destinationCornerRadius: CGFloat if transitionContext.transitionWasCancelled { destinationFromAlpha = 1 - destinationFrame = fromMediaContext.presentationFrame + destinationFrameInContainer = fromMediaContext.presentationFrame destinationCornerRadius = fromMediaContext.cornerRadius + + if let transitionSuperview: UIView = transitionView.superview { + destinationFrame = transitionSuperview.convert(destinationFrameInContainer, from: containerView) + } else { + destinationFrame = destinationFrameInContainer + } } else if let toMediaContext: MediaPresentationContext = toMediaContext { destinationFromAlpha = 0 - destinationFrame = toMediaContext.presentationFrame + destinationFrameInContainer = toMediaContext.presentationFrame destinationCornerRadius = toMediaContext.cornerRadius + + if let transitionSuperview: UIView = transitionView.superview { + destinationFrame = transitionSuperview.convert(toMediaContext.presentationFrame, from: containerView) + } else { + destinationFrame = destinationFrameInContainer + } } else { // `toMediaContext` can be nil if the target item is scrolled off of the // contextProvider's screen, so we synthesize a context to dismiss the item // off screen destinationFromAlpha = 0 - destinationFrame = fromMediaContext.presentationFrame - .offsetBy(dx: 0, dy: (containerView.bounds.height * 2)) + destinationFrameInContainer = fromMediaContext.presentationFrame.offsetBy(dx: 0, dy: containerView.bounds.height * 2) + destinationFrame = transitionView.frame.offsetBy(dx: 0, dy: (containerView.bounds.height * 2)) destinationCornerRadius = fromMediaContext.cornerRadius } @@ -208,19 +141,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning delay: 0, options: [.beginFromCurrentState, .curveEaseInOut], animations: { [weak self] in - self?.fromTransitionalOverlayView?.alpha = destinationFromAlpha self?.fromView?.alpha = destinationFromAlpha - self?.toTransitionalOverlayView?.alpha = (1.0 - destinationFromAlpha) transitionView.frame = destinationFrame transitionView.layer.cornerRadius = destinationCornerRadius }, completion: { [weak self] _ in + self?.stopDisplayLink() + self?.removeMask() + self?.fromView?.alpha = 1 fromMediaContext.mediaView.alpha = 1 toMediaContext?.mediaView.alpha = 1 transitionView.removeFromSuperview() - self?.fromTransitionalOverlayView?.removeFromSuperview() - self?.toTransitionalOverlayView?.removeFromSuperview() if transitionContext.transitionWasCancelled { // The "to" view will be nil if we're doing a modal dismiss, in which case @@ -240,6 +172,8 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning } transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + + self?.cleanUp() } ) } @@ -252,7 +186,9 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning } private func fallbackTransition(context: UIViewControllerContextTransitioning) { - let containerView = context.containerView + cleanUp() + + let containerView: UIView = context.containerView /// iOS won't automatically handle failure cases so if we can't get the "from" context then we want to just complete /// the change instantly so the user doesn't permanently get stuck on the screen diff --git a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift index 735d8b294d..c6836cedce 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift @@ -30,8 +30,288 @@ struct MediaPresentationContext { // stop showing the media pager. This can be a pop to the tile view, or a modal dismiss. protocol MediaPresentationContextProvider { func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? + func lowestViewToRenderAboveContent() -> UIView? +} + +private struct ClippedHole { + let frame: CGRect + let clippedCornerRadius: CGFloat + let maskedCorners: CACornerMask + + init(holeFrame: CGRect, cornerRadius: CGFloat, viewport: CGRect?) { + guard let viewport: CGRect = viewport else { + self.frame = holeFrame + self.clippedCornerRadius = cornerRadius + self.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] + return + } + + self.frame = holeFrame.intersection(viewport) + var corners: CACornerMask = [] + + // Top-left corner + if holeFrame.minX >= viewport.minX && holeFrame.minY >= viewport.minY { + corners.insert(.layerMinXMinYCorner) + } + + // Top-right corner + if holeFrame.maxX <= viewport.maxX && holeFrame.minY >= viewport.minY { + corners.insert(.layerMaxXMinYCorner) + } + + // Bottom-left corner + if holeFrame.minX >= viewport.minX && holeFrame.maxY <= viewport.maxY { + corners.insert(.layerMinXMaxYCorner) + } + + // Bottom-right corner + if holeFrame.maxX <= viewport.maxX && holeFrame.maxY <= viewport.maxY { + corners.insert(.layerMaxXMaxYCorner) + } + + self.clippedCornerRadius = (corners.isEmpty ? 0 : cornerRadius) + self.maskedCorners = corners + } + + func createPath() -> UIBezierPath { + if maskedCorners.isEmpty || clippedCornerRadius == 0 { + return UIBezierPath(rect: frame) + } + + let path: UIBezierPath = UIBezierPath() + let topLeft: CGPoint = frame.origin + let topRight: CGPoint = CGPoint(x: frame.maxX, y: frame.minY) + let bottomRight: CGPoint = CGPoint(x: frame.maxX, y: frame.maxY) + let bottomLeft: CGPoint = CGPoint(x: frame.minX, y: frame.maxY) + + // Start from top-left + if maskedCorners.contains(.layerMinXMinYCorner) { + path.move(to: CGPoint(x: topLeft.x + clippedCornerRadius, y: topLeft.y)) + path.addArc( + withCenter: CGPoint(x: topLeft.x + clippedCornerRadius, y: topLeft.y + clippedCornerRadius), + radius: clippedCornerRadius, + startAngle: .pi * 1.5, + endAngle: .pi, + clockwise: false + ) + } else { + path.move(to: topLeft) + } + + // Left edge to bottom-left + if maskedCorners.contains(.layerMinXMaxYCorner) { + path.addLine(to: CGPoint(x: bottomLeft.x, y: bottomLeft.y - clippedCornerRadius)) + path.addArc( + withCenter: CGPoint(x: bottomLeft.x + clippedCornerRadius, y: bottomLeft.y - clippedCornerRadius), + radius: clippedCornerRadius, + startAngle: .pi, + endAngle: .pi * 0.5, + clockwise: false + ) + } else { + path.addLine(to: bottomLeft) + } + + // Bottom edge to bottom-right + if maskedCorners.contains(.layerMaxXMaxYCorner) { + path.addLine(to: CGPoint(x: bottomRight.x - clippedCornerRadius, y: bottomRight.y)) + path.addArc( + withCenter: CGPoint(x: bottomRight.x - clippedCornerRadius, y: bottomRight.y - clippedCornerRadius), + radius: clippedCornerRadius, + startAngle: .pi * 0.5, + endAngle: 0, + clockwise: false + ) + } else { + path.addLine(to: bottomRight) + } + + // Right edge to top-right + if maskedCorners.contains(.layerMaxXMinYCorner) { + path.addLine(to: CGPoint(x: topRight.x, y: topRight.y + clippedCornerRadius)) + path.addArc( + withCenter: CGPoint(x: topRight.x - clippedCornerRadius, y: topRight.y + clippedCornerRadius), + radius: clippedCornerRadius, + startAngle: 0, + endAngle: .pi * 1.5, + clockwise: false + ) + } else { + path.addLine(to: topRight) + } + + path.close() + return path + } +} + +class MediaAnimationController: NSObject { + let dependencies: Dependencies + let attachment: Attachment + + private var displayLink: CADisplayLink? + private var maskLayer: CAShapeLayer? + private weak var maskedView: UIView? + private var maskViewport: CGRect? + private(set) weak var transitionView: UIView? + + private var fromBelowContainer: UIView? + private var toBelowContainer: UIView? + private var fromAboveViews: [UIView] = [] + private var toAboveViews: [UIView] = [] + + init(attachment: Attachment, using dependencies: Dependencies) { + self.dependencies = dependencies + self.attachment = attachment + + super.init() + } + + func extractContextProvider(from viewController: UIViewController) -> MediaPresentationContextProvider? { + switch viewController { + case let contextProvider as MediaPresentationContextProvider: + return contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { return nil } + + return contextProvider + + case let navController as UINavigationController: + guard + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { return nil } + + return contextProvider + + default: return nil + } + } + + func extractNavigationController(from viewController: UIViewController) -> UINavigationController? { + switch viewController { + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController + else { return nil } + + return navController - // The transitionView will be presented below this view. - // If nil, the transitionView will be presented above all - func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? + case let navController as UINavigationController: return navController + default: return nil + } + } + + func createAndAddMask( + to view: UIView, + holeFrame: CGRect, + cornerRadius: CGFloat, + in containerView: UIView, + viewport: CGRect? = nil + ) { + let maskLayer: CAShapeLayer = CAShapeLayer() + let holeFrameInView: CGRect = view.convert(holeFrame, from: containerView) + let viewportInView: CGRect? = viewport.map { view.convert($0, from: containerView) } + let clippedHole: ClippedHole = ClippedHole( + holeFrame: holeFrameInView, + cornerRadius: cornerRadius, + viewport: viewportInView + ) + + let path: UIBezierPath = UIBezierPath(rect: view.bounds) + let hole: UIBezierPath = clippedHole.createPath() + path.append(hole) + path.usesEvenOddFillRule = true + maskLayer.path = path.cgPath + maskLayer.fillRule = .evenOdd + view.layer.mask = maskLayer + + self.maskLayer = maskLayer + self.maskedView = view + self.maskViewport = viewport + } + + func insertTransitionView( + _ transitionView: UIView, + intoView view: UIView, + contextProvider: MediaPresentationContextProvider, + startFrame: CGRect, + containerView: UIView + ) { + self.transitionView = transitionView + + if + let targetView: UIView = contextProvider.lowestViewToRenderAboveContent(), + let targetSuperview: UIView = targetView.superview + { + let frameInSuperview: CGRect = targetSuperview.convert(startFrame, from: containerView) + transitionView.frame = frameInSuperview + targetSuperview.insertSubview(transitionView, belowSubview: targetView) + } + else if let navBar: UIView = view.subviews.first(where: { $0 is UINavigationBar }) { + let frameInTargetView: CGRect = view.convert(startFrame, from: containerView) + transitionView.frame = frameInTargetView + view.insertSubview(transitionView, belowSubview: navBar) + } else { + transitionView.frame = startFrame + containerView.insertSubview(transitionView, at: 0) + } + } + + func startDisplayLink() { + let displayLink: CADisplayLink = CADisplayLink(target: self, selector: #selector(updateMask)) + displayLink.add(to: .main, forMode: .common) + self.displayLink = displayLink + } + + func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + func removeMask() { + maskedView?.layer.mask = nil + maskLayer = nil + maskedView = nil + } + + func cleanUp() { + stopDisplayLink() + maskLayer = nil + maskedView = nil + transitionView = nil + } + + @objc private func updateMask() { + guard + let maskLayer: CAShapeLayer = self.maskLayer, + let maskedView: UIView = self.maskedView, + let transitionView: UIView = self.transitionView, + let containerView: UIWindow = transitionView.window + else { return } + + let currentFrameInSuperview: CGRect = (transitionView.layer.presentation()?.frame ?? transitionView.frame) + let currentCornerRadius: CGFloat = (transitionView.layer.presentation()?.cornerRadius ?? transitionView.layer.cornerRadius) + let currentFrameInContainer: CGRect = (transitionView.superview?.convert(currentFrameInSuperview, to: containerView) ?? currentFrameInSuperview) + let holeFrameInView: CGRect = maskedView.convert(currentFrameInContainer, from: containerView) + let viewportInView: CGRect? = self.maskViewport.map { maskedView.convert($0, from: containerView) } + let clippedHole: ClippedHole = ClippedHole( + holeFrame: holeFrameInView, + cornerRadius: currentCornerRadius, + viewport: viewportInView + ) + + // Update the mask path + let path: UIBezierPath = UIBezierPath(rect: maskedView.bounds) + let hole: UIBezierPath = clippedHole.createPath() + path.append(hole) + path.usesEvenOddFillRule = true + + maskLayer.path = path.cgPath + } } diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift index 9660af4136..28f6973ff2 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -5,15 +5,13 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -class MediaZoomAnimationController: NSObject { - private let dependencies: Dependencies - private let attachment: Attachment +class MediaZoomAnimationController: MediaAnimationController { private let shouldBounce: Bool init(attachment: Attachment, shouldBounce: Bool = true, using dependencies: Dependencies) { - self.dependencies = dependencies - self.attachment = attachment self.shouldBounce = shouldBounce + + super.init(attachment: attachment, using: dependencies) } } @@ -21,18 +19,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.3 } - + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { let containerView = transitionContext.containerView - let fromContextProvider: MediaPresentationContextProvider - let toContextProvider: MediaPresentationContextProvider - + /// Can't recover if we don't have an origin or destination so don't bother trying guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from), - let toVC: UIViewController = transitionContext.viewController(forKey: .to) - else { return transitionContext.completeTransition(false) } - + let toVC: UIViewController = transitionContext.viewController(forKey: .to), + let fromContextProvider: MediaPresentationContextProvider = extractContextProvider(from: fromVC), + let toContextProvider: MediaPresentationContextProvider = extractContextProvider(from: toVC) + else { return fallbackTransition(context: transitionContext) } + /// `view(forKey: .to)` will be nil when using this transition for a modal dismiss, in which case we want to use the /// `toVC.view` but need to ensure we add it back to it's original parent afterwards so we don't break the view hierarchy /// @@ -40,70 +38,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { /// the `toContextProvider.mediaPresentationContext` is dependant on it having the correct positioning (and /// the navBar sizing isn't correct until after layout) let toView: UIView = (transitionContext.view(forKey: .to) ?? toVC.view) + let fromView: UIView = (transitionContext.view(forKey: .from) ?? fromVC.view) let duration: CGFloat = transitionDuration(using: transitionContext) let oldToViewSuperview: UIView? = toView.superview toView.layoutIfNeeded() - switch fromVC { - case let contextProvider as MediaPresentationContextProvider: - fromContextProvider = contextProvider - - case let topBannerController as TopBannerController: - guard - let firstChild: UIViewController = topBannerController.children.first, - let navController: UINavigationController = firstChild as? UINavigationController, - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(toView: toView, context: transitionContext) } - - fromContextProvider = contextProvider - - case let navController as UINavigationController: - guard - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(toView: toView, context: transitionContext) } - - fromContextProvider = contextProvider - - default: return fallbackTransition(toView: toView, context: transitionContext) - } - - switch toVC { - case let contextProvider as MediaPresentationContextProvider: - toContextProvider = contextProvider - - case let topBannerController as TopBannerController: - guard - let firstChild: UIViewController = topBannerController.children.first, - let navController: UINavigationController = firstChild as? UINavigationController, - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(toView: toView, context: transitionContext) } - - toContextProvider = contextProvider - - case let navController as UINavigationController: - guard - let contextProvider = navController.topViewController as? MediaPresentationContextProvider - else { return fallbackTransition(toView: toView, context: transitionContext) } - - toContextProvider = contextProvider - - default: return fallbackTransition(toView: toView, context: transitionContext) - } - // If we can't retrieve the contextual info we need to perform the proper zoom animation then // just fade the destination in (otherwise the user would get stuck on a blank screen) guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaId: attachment.id, in: containerView), - let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaId: attachment.id, in: containerView), - let presentationSource: ImageDataManager.DataSource = ImageDataManager.DataSource.from( - attachment: attachment, - using: dependencies - ) - else { return fallbackTransition(toView: toView, context: transitionContext) } - - fromMediaContext.mediaView.alpha = 0 - toMediaContext.mediaView.alpha = 0 - + let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaId: attachment.id, in: containerView) + else { return fallbackTransition(context: transitionContext) } + toView.frame = containerView.bounds toView.alpha = 0 containerView.addSubview(toView) @@ -111,92 +57,106 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { let transitionView: SessionImageView = SessionImageView( dataManager: dependencies[singleton: .imageDataManager] ) - transitionView.loadImage(presentationSource) + transitionView.copyContentAndAnimationPoint(from: fromMediaContext.mediaView) transitionView.frame = fromMediaContext.presentationFrame transitionView.contentMode = MediaView.contentMode transitionView.layer.masksToBounds = true transitionView.layer.cornerRadius = fromMediaContext.cornerRadius transitionView.layer.maskedCorners = fromMediaContext.cornerMask - containerView.addSubview(transitionView) - // Set the currently loaded image to prevent any odd delay and try to match the animation - // state to the source - transitionView.image = fromMediaContext.mediaView.image + insertTransitionView( + transitionView, + intoView: fromView, + contextProvider: fromContextProvider, + startFrame: fromMediaContext.presentationFrame, + containerView: containerView + ) - if fromMediaContext.mediaView.isAnimating { - transitionView.startAnimationLoop() - transitionView.setAnimationPoint( - index: fromMediaContext.mediaView.currentFrameIndex, - time: fromMediaContext.mediaView.accumulatedTime - ) - } + fromMediaContext.mediaView.alpha = 0 + toMediaContext.mediaView.alpha = 0 - // Note: We need to do this after adding the 'transitionView' and insert it at the back - // otherwise the screen can flicker since we have 'afterScreenUpdates: true' (if we use - // 'afterScreenUpdates: false' then the 'fromMediaContext.mediaView' won't be hidden - // during the transition) - let fromSnapshotView: UIView = (fromVC.view.snapshotView(afterScreenUpdates: true) ?? UIView()) - containerView.insertSubview(fromSnapshotView, at: 0) - - // Add any UI elements which should appear above the media view - let fromTransitionalOverlayView: UIView? = { - guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else { - return nil + // Add a mask so we can transition nicely between the views and slide the content between + // any header/footer views + let viewport: CGRect = { + let navBarView: UIView? = fromView.subviews.first(where: { $0 is UINavigationBar }) + var topY: CGFloat = (navBarView?.frame.maxY ?? 0) + var bottomY: CGFloat = fromView.bounds.height + + guard + let lowestAboveView: UIView = fromContextProvider.lowestViewToRenderAboveContent(), + let lowestAboveIndex: Array.Index = fromView.subviews.firstIndex(of: lowestAboveView) + else { + return CGRect(x: 0, y: topY, width: fromView.bounds.width, height: bottomY) } - - overlayView.frame = overlayViewFrame - containerView.addSubview(overlayView) - - return overlayView - }() - let toTransitionalOverlayView: UIView? = { - guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else { - return nil + + for aboveView in fromView.subviews.suffix(from: lowestAboveIndex) { + if aboveView.frame.minY < topY && aboveView.frame.maxY > topY { + topY = aboveView.frame.maxY + } + else if aboveView.frame.minY > bottomY { + bottomY = min(bottomY, aboveView.frame.minY) + } } - - overlayView.alpha = 0 - overlayView.frame = overlayViewFrame - containerView.addSubview(overlayView) - - return overlayView + + return CGRect( + x: 0, + y: topY, + width: containerView.bounds.width, + height: bottomY - topY + ) }() + createAndAddMask( + to: toView, + holeFrame: fromMediaContext.presentationFrame, + cornerRadius: fromMediaContext.cornerRadius, + in: containerView, + viewport: viewport + ) + + // Start display link to update mask during animation + startDisplayLink() + + let destinationFrame: CGRect + if let transitionSuperview: UIView = transitionView.superview { + destinationFrame = transitionSuperview.convert(toMediaContext.presentationFrame, from: containerView) + } else { + destinationFrame = toMediaContext.presentationFrame + } UIView.animate( withDuration: duration, delay: 0, options: .curveEaseInOut, animations: { - // Only fade out the 'fromTransitionalOverlayView' if it's bigger than the destination - // one (makes it look cleaner as you don't get the crossfade effect) - if (fromTransitionalOverlayView?.frame.size.height ?? 0) > (toTransitionalOverlayView?.frame.size.height ?? 0) { - fromTransitionalOverlayView?.alpha = 0 - } - toView.alpha = 1 - toTransitionalOverlayView?.alpha = 1 - transitionView.frame = toMediaContext.presentationFrame + transitionView.frame = destinationFrame transitionView.layer.cornerRadius = toMediaContext.cornerRadius }, - completion: { _ in + completion: { [weak self] _ in + self?.stopDisplayLink() + self?.removeMask() + transitionView.removeFromSuperview() - fromSnapshotView.removeFromSuperview() - fromTransitionalOverlayView?.removeFromSuperview() - toTransitionalOverlayView?.removeFromSuperview() - + toMediaContext.mediaView.alpha = 1 fromMediaContext.mediaView.alpha = 1 - + // Need to ensure we add the 'toView' back to it's old superview if it had one oldToViewSuperview?.addSubview(toView) - + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + + self?.cleanUp() } ) } - private func fallbackTransition(toView: UIView, context: UIViewControllerContextTransitioning) { + private func fallbackTransition(context: UIViewControllerContextTransitioning) { + cleanUp() + let duration: CGFloat = transitionDuration(using: context) let containerView = context.containerView + let toView: UIView = (context.view(forKey: .to) ?? context.viewController(forKey: .to)?.view ?? UIView()) let oldToViewSuperview: UIView? = toView.superview toView.frame = containerView.bounds toView.alpha = 0 @@ -212,7 +172,7 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { completion: { _ in // Need to ensure we add the 'toView' back to it's old superview if it had one oldToViewSuperview?.addSubview(toView) - + context.completeTransition(!context.transitionWasCancelled) } ) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 72d0ee2c58..c4e8222d79 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -1065,16 +1065,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard let presentingVC = dependencies[singleton: .appContext].frontMostViewController else { preconditionFailure() } let callVC: CallVC = CallVC(for: call, using: dependencies) - - if - let conversationVC: ConversationVC = (presentingVC as? TopBannerController)?.wrappedViewController() as? ConversationVC, - conversationVC.viewModel.threadData.threadId == call.sessionId - { - callVC.conversationVC = conversationVC - conversationVC.resignFirstResponder() - conversationVC.hideInputAccessoryView() - } - presentingVC.present(callVC, animated: true, completion: nil) } } diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 1f146f1257..9eeece9795 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -14,6 +14,7 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { var maxFileSize: UInt { Network.maxFileSize } var isStorageValid: Bool { dependencies[singleton: .storage].isValid } + var isRTL: Bool { Dependencies.isRTL } // MARK: - Initialization diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 5d833e6363..4e06935cb5 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -18,7 +18,7 @@ final class ThemeMessagePreviewView: UIView { with: MessageViewModel( variant: .standardIncoming, body: "appearancePreview2".localized(), - quotedInfo: MessageViewModel.QuotedInfo(previewBody: "appearancePreview1".localized()), + quoteViewModel: QuoteViewModel(previewBody: "appearancePreview1".localized()), cellType: .textOnlyMessage ), playbackInfo: nil, diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index e1e17b647e..cab7c68d8d 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -157,13 +157,13 @@ public extension SessionCell.Accessory { static func profile( id: String, - size: ProfilePictureView.Size = .list, + size: ProfilePictureView.Info.Size = .list, threadVariant: SessionThread.Variant = .contact, displayPictureUrl: String? = nil, profile: Profile? = nil, - profileIcon: ProfilePictureView.ProfileIcon = .none, + profileIcon: ProfilePictureView.Info.ProfileIcon = .none, additionalProfile: Profile? = nil, - additionalProfileIcon: ProfilePictureView.ProfileIcon = .none, + additionalProfileIcon: ProfilePictureView.Info.ProfileIcon = .none, accessibility: Accessibility? = nil ) -> SessionCell.Accessory { return SessionCell.AccessoryConfig.DisplayPicture( @@ -572,23 +572,23 @@ public extension SessionCell.AccessoryConfig { override public var viewIdentifier: String { "displayPicture-\(size.viewSize)" } public let id: String - public let size: ProfilePictureView.Size + public let size: ProfilePictureView.Info.Size public let threadVariant: SessionThread.Variant public let displayPictureUrl: String? public let profile: Profile? - public let profileIcon: ProfilePictureView.ProfileIcon + public let profileIcon: ProfilePictureView.Info.ProfileIcon public let additionalProfile: Profile? - public let additionalProfileIcon: ProfilePictureView.ProfileIcon + public let additionalProfileIcon: ProfilePictureView.Info.ProfileIcon fileprivate init( id: String, - size: ProfilePictureView.Size, + size: ProfilePictureView.Info.Size, threadVariant: SessionThread.Variant, displayPictureUrl: String?, profile: Profile?, - profileIcon: ProfilePictureView.ProfileIcon, + profileIcon: ProfilePictureView.Info.ProfileIcon, additionalProfile: Profile?, - additionalProfileIcon: ProfilePictureView.ProfileIcon, + additionalProfileIcon: ProfilePictureView.Info.ProfileIcon, accessibility: Accessibility? ) { self.id = id diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 6412cd1e42..067ee83b5b 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -682,7 +682,7 @@ extension SessionCell { return ProfilePictureView(size: .list, dataManager: nil) } - private func layoutDisplayPictureView(_ view: UIView?, size: ProfilePictureView.Size) { + private func layoutDisplayPictureView(_ view: UIView?, size: ProfilePictureView.Info.Size) { guard let profilePictureView: ProfilePictureView = view as? ProfilePictureView else { return } profilePictureView.size = size diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index b03942a719..ea9356da5b 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -5,6 +5,7 @@ import Foundation import Combine import GRDB +import SessionUIKit import SessionNetworkingKit import SessionMessagingKit import SessionUtilitiesKit diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/Session/Utilities/ImageLoading+Convenience.swift index 2def00a7aa..24d72be03b 100644 --- a/Session/Utilities/ImageLoading+Convenience.swift +++ b/Session/Utilities/ImageLoading+Convenience.swift @@ -37,24 +37,59 @@ public extension ImageDataManager.DataSource { attachment: Attachment, size: ImageDataManager.ThumbnailSize, using dependencies: Dependencies + ) -> ImageDataManager.DataSource? { + guard let path: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl) else { + return nil + } + + return thumbnailFrom( + utType: (UTType(sessionMimeType: attachment.contentType) ?? .invalid), + path: path, + sourceFilename: attachment.sourceFilename, + size: size, + using: dependencies + ) + } + + static func thumbnailFrom( + quoteViewModel: QuoteViewModel, + using dependencies: Dependencies ) -> ImageDataManager.DataSource? { guard - attachment.isVisualMedia, + let info: QuoteViewModel.AttachmentInfo = quoteViewModel.quotedAttachmentInfo, let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: attachment.downloadUrl) + .path(for: info.downloadUrl) else { return nil } + return .thumbnailFrom( + utType: info.utType, + path: path, + sourceFilename: info.sourceFilename, + size: .small, + using: dependencies + ) + } + + static func thumbnailFrom( + utType: UTType, + path: String, + sourceFilename: String? = nil, + size: ImageDataManager.ThumbnailSize, + using dependencies: Dependencies + ) -> ImageDataManager.DataSource? { + guard utType != .invalid else { return nil } + /// Can't thumbnail animated images so just load the full file in this case - if attachment.isAnimated { + if utType.isAnimated { return .url(URL(fileURLWithPath: path)) } /// Videos have a custom method for generating their thumbnails so use that instead - if attachment.isVideo { + if utType.isVideo { return .videoUrl( URL(fileURLWithPath: path), - (UTType(sessionMimeType: attachment.contentType) ?? .invalid), - attachment.sourceFilename, + utType, + sourceFilename, dependencies[singleton: .attachmentManager] ) } @@ -176,3 +211,21 @@ public extension SessionAsyncImage { ) } } + +// MARK: - Attachment Convenience + +public extension Attachment { + func quoteAttachmentInfo(using dependencies: Dependencies) -> QuoteViewModel.AttachmentInfo? { + guard let utType: UTType = UTType(sessionMimeType: contentType) else { return nil } + + return QuoteViewModel.AttachmentInfo( + id: id, + utType: utType, + isVoiceMessage: (variant == .voiceMessage), + downloadUrl: downloadUrl, + sourceFilename: sourceFilename, + thumbnailSource: .thumbnailFrom(attachment: self, size: .small, using: dependencies) + ) + } +} + diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift index a2059109dc..e818ecac4e 100644 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -16,14 +16,10 @@ public extension MentionUtilities { return MentionUtilities.highlightMentionsNoAttributes( in: string, currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: { sessionId, _ in - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - return Profile.displayNameNoFallback( - id: sessionId, - threadVariant: threadVariant, - using: dependencies - ) - } + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: threadVariant, + using: dependencies + ) ) } @@ -42,14 +38,10 @@ public extension MentionUtilities { location: location, textColor: textColor, attributes: attributes, - displayNameRetriever: { sessionId, _ in - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - return Profile.displayNameNoFallback( - id: sessionId, - threadVariant: threadVariant, - using: dependencies - ) - } + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: threadVariant, + using: dependencies + ) ) } } diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 7712a71369..ca130970e6 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -43,7 +43,7 @@ enum MockDataGenerator { let logProgress: (String, String) -> () = { title, event in guard printProgress else { return } - print("[MockDataGenerator] (\(Date().timeIntervalSince1970)) \(title) - \(event)") + Log.debug("[MockDataGenerator] (\(Date().timeIntervalSince1970)) \(title) - \(event)") } hasStartedGenerationThisRun = true diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 7443b67958..ad1639b5ea 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -9,6 +9,14 @@ import SessionUtilitiesKit import SessionMessagingKit import Network +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("Permissions", defaultLevel: .off) +} + +// MARK: - Permissions + extension Permissions { @MainActor @discardableResult public static func requestCameraPermissionIfNeeded( presentingViewController: UIViewController? = nil, @@ -223,7 +231,7 @@ extension Permissions { let local = LocalState() @Sendable func resume(with result: Result) { if local.didResume { - print("Already resumed, ignoring subsequent result.") + Log.debug(.cat, "Already resumed, ignoring subsequent result.") return } local.didResume = true @@ -247,20 +255,20 @@ extension Permissions { listener.stateUpdateHandler = { newState in switch newState { case .setup: - print("Listener performing setup.") + Log.debug(.cat, "Listener performing setup.") case .ready: - print("Listener ready to be discovered.") + Log.debug(.cat, "Listener ready to be discovered.") case .cancelled: - print("Listener cancelled.") + Log.debug(.cat, "Listener cancelled.") resume(with: .failure(CancellationError())) case .failed(let error): - print("Listener failed, stopping. \(error)") + Log.debug(.cat, "Listener failed, stopping. \(error)") resume(with: .failure(error)) case .waiting(let error): - print("Listener waiting, stopping. \(error)") + Log.debug(.cat, "Listener waiting, stopping. \(error)") resume(with: .failure(error)) @unknown default: - print("Ignoring unknown listener state: \(String(describing: newState))") + Log.debug(.cat, "Ignoring unknown listener state: \(String(describing: newState))") } } listener.start(queue: queue) @@ -268,46 +276,46 @@ extension Permissions { browser.stateUpdateHandler = { newState in switch newState { case .setup: - print("Browser performing setup.") + Log.debug(.cat, "Browser performing setup.") return case .ready: - print("Browser ready to discover listeners.") + Log.debug(.cat, "Browser ready to discover listeners.") return case .cancelled: - print("Browser cancelled.") + Log.debug(.cat, "Browser cancelled.") resume(with: .failure(CancellationError())) case .failed(let error): - print("Browser failed, stopping. \(error)") + Log.debug(.cat, "Browser failed, stopping. \(error)") resume(with: .failure(error)) case let .waiting(error): switch error { case .dns(DNSServiceErrorType(kDNSServiceErr_PolicyDenied)): - print("Browser permission denied, reporting failure.") + Log.debug(.cat, "Browser permission denied, reporting failure.") resume(with: .success(false)) default: - print("Browser waiting, stopping. \(error)") + Log.debug(.cat, "Browser waiting, stopping. \(error)") resume(with: .failure(error)) } @unknown default: - print("Ignoring unknown browser state: \(String(describing: newState))") + Log.debug(.cat, "Ignoring unknown browser state: \(String(describing: newState))") return } } browser.browseResultsChangedHandler = { results, changes in if results.isEmpty { - print("Got empty result set from browser, ignoring.") + Log.debug(.cat, "Got empty result set from browser, ignoring.") return } - print("Discovered \(results.count) listeners, reporting success.") + Log.debug(.cat, "Discovered \(results.count) listeners, reporting success.") resume(with: .success(true)) } browser.start(queue: queue) // Task cancelled while setting up listener & browser, tear down immediatly if Task.isCancelled { - print("Task cancelled during listener & browser start. (Some warnings might be logged by the listener or browser.)") + Log.debug(.cat, "Task cancelled during listener & browser start. (Some warnings might be logged by the listener or browser.)") resume(with: .failure(CancellationError())) return } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index f5bff7ce4a..532d5fccd8 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -579,19 +579,9 @@ extension Attachment { public var documentFileName: String { if let sourceFilename: String = sourceFilename { return sourceFilename } - return shortDescription - } - - public var shortDescription: String { - if isImage { return "image".localized() } - if isAudio { - switch variant { - case .voiceMessage: return "messageVoice".localized() - case .standard: return "audio".localized() - } - } - if isVideo { return "video".localized() } - return "document".localized() + + return (UTType(sessionMimeType: contentType) ?? .invalid) + .shortDescription(isVoiceMessage: (variant == .voiceMessage)) } public var documentFileInfo: String { diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 37e78424be..fe278e0665 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -22,7 +22,7 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis case isHidden } - public enum Role: Int, Codable, Comparable, DatabaseValueConvertible { + public enum Role: Int, Codable, Comparable, CaseIterable, DatabaseValueConvertible { case standard case zombie case moderator @@ -128,7 +128,7 @@ public extension GroupMember { } extension GroupMember: ProfileAssociated { - public var profileIcon: ProfilePictureView.ProfileIcon { + public var profileIcon: ProfilePictureView.Info.ProfileIcon { switch role { case .moderator, .admin: return .crown default: return .none diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 35f4225f80..130a09ba94 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -281,6 +281,21 @@ public extension Profile { semaphore.wait() return displayName } + + @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") + static func defaultDisplayNameRetriever( + threadVariant: SessionThread.Variant = .contact, + using dependencies: Dependencies + ) -> ((String, Bool) -> String?) { + // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) + return { sessionId, _ in + Profile.displayNameNoFallback( + id: sessionId, + threadVariant: threadVariant, + using: dependencies + ) + } + } } @@ -386,7 +401,7 @@ extension WithProfile: Differentiable where T: Differentiable {} public protocol ProfileAssociated: Equatable, Hashable { var profileId: String { get } - var profileIcon: ProfilePictureView.ProfileIcon { get } + var profileIcon: ProfilePictureView.Info.ProfileIcon { get } func itemDescription(using dependencies: Dependencies) -> String? func itemDescriptionColor(using dependencies: Dependencies) -> ThemeValue @@ -394,7 +409,7 @@ public protocol ProfileAssociated: Equatable, Hashable { } public extension ProfileAssociated { - var profileIcon: ProfilePictureView.ProfileIcon { return .none } + var profileIcon: ProfilePictureView.Info.ProfileIcon { return .none } func itemDescription(using dependencies: Dependencies) -> String? { return nil } func itemDescriptionColor(using dependencies: Dependencies) -> ThemeValue { return .textPrimary } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e0b7269ffd..f59a23dd5b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -884,7 +884,44 @@ public final class OpenGroupManager { return capabilities.contains(capability) } - /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group + /// This method specifies if the given publicKeys have moderator or admin permissions within a specified Open Group + public func membersWhere( + _ db: ObservingDatabase, + currentUserSessionIds: Set, + _ filters: GroupMember.Filter... + ) throws -> [GroupMember] { + var query: QueryInterfaceRequest = GroupMember.select(.allColumns) + + /// Apply the desired filters + for filter in filters { + switch filter { + case .groupIds(let ids): query = query.filter(ids.contains(GroupMember.Columns.groupId)) + case .roles(let roles): query = query.filter(roles.contains(GroupMember.Columns.role)) + + case .publicKeys(let keys): + var targetKeys: Set = Set(keys) + + /// If `currentUserSessionIds` contains one of the `publicKeys` then we want to include `currentUserSessionIds` + /// in the lookup + if !currentUserSessionIds.isDisjoint(with: targetKeys) { + targetKeys.insert(contentsOf: currentUserSessionIds) + + /// Add the users `unblinded` pubkey if we can get it, just for completeness + let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) + if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { + targetKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) + } + } + + query = query.filter(targetKeys.contains(GroupMember.Columns.profileId)) + } + } + + return try query.fetchAll(db) + } + public func isUserModeratorOrAdmin( _ db: ObservingDatabase, publicKey: String, @@ -895,28 +932,39 @@ public final class OpenGroupManager { guard let roomToken: String = roomToken, let server: String = server else { return false } let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) - let targetRoles: [GroupMember.Role] = [.moderator, .admin] - var possibleKeys: Set = [publicKey] + let members: [GroupMember]? = try? membersWhere( + db, + currentUserSessionIds: currentUserSessionIds, + .groupIds([groupId]), + .publicKeys([publicKey]), + .roles([.moderator, .admin]) + ) + + var targetKeys: Set = Set([publicKey]) - /// If the `publicKey` is in `currentUserSessionIds` then we want to use `currentUserSessionIds` to do - /// the lookup - if currentUserSessionIds.contains(publicKey) { - possibleKeys = currentUserSessionIds + /// If `currentUserSessionIds` contains one of the `publicKeys` then we want to include `currentUserSessionIds` + /// in the lookup + if !currentUserSessionIds.isDisjoint(with: targetKeys) { + targetKeys.insert(contentsOf: currentUserSessionIds) /// Add the users `unblinded` pubkey if we can get it, just for completeness let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { - possibleKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) + targetKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) } } - return GroupMember - .filter(GroupMember.Columns.groupId == groupId) - .filter(possibleKeys.contains(GroupMember.Columns.profileId)) - .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db) + return (Set((members ?? []).map { $0.profileId }).isDisjoint(with: targetKeys) == false) + } +} + +public extension GroupMember { + enum Filter { + case groupIds(any Collection) + case publicKeys(any Collection) + case roles(any Collection) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index e322563198..40ae6b677c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -3,6 +3,7 @@ import Foundation import Combine import GRDB +import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift index 6856571469..0185715f34 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift @@ -4,6 +4,7 @@ import Foundation import Combine +import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index b7b8b8adfc..c29c7c1979 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -1,7 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import UniformTypeIdentifiers import GRDB +import SessionUIKit +import SessionUtilitiesKit public struct QuotedReplyModel { public let threadId: String diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift deleted file mode 100644 index 71fbe7c26b..0000000000 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import GRDB -import SessionUtilitiesKit - -public struct MentionInfo: FetchableRecord, Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case profile - case threadVariant - case openGroupServer - case openGroupRoomToken - } - - public let profile: Profile - public let threadVariant: SessionThread.Variant - public let openGroupServer: String? - public let openGroupRoomToken: String? - - public init(profile: Profile, threadVariant: SessionThread.Variant, openGroupServer: String? = nil, openGroupRoomToken: String? = nil) { - self.profile = profile - self.threadVariant = threadVariant - self.openGroupServer = openGroupServer - self.openGroupRoomToken = openGroupRoomToken - } -} - -public extension MentionInfo { - // stringlint:ignore_contents - static func query( - threadId: String, - threadVariant: SessionThread.Variant, - targetPrefixes: [SessionId.Prefix], - currentUserSessionIds: Set, - pattern: FTS5Pattern? - ) -> AdaptedFetchRequest>? { - let profile: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let prefixesLiteral: SQLExpression = targetPrefixes - .map { prefix in - SQL( - """ - ( - \(profile[.id]) > '\(SQL(stringLiteral: "\(prefix.rawValue)"))' AND - \(profile[.id]) < '\(SQL(stringLiteral: "\(prefix.endOfRangeString)"))' - ) - """) - } - .joined(operator: .or) - let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) - - /// The query needs to differ depending on the thread variant because the behaviour should be different: - /// - /// **Contact:** We should show the profile directly (filtered out if the pattern doesn't match) - /// **Group:** We should show all profiles within the group, filtered by the pattern - /// **Community:** We should show only the 20 most recent profiles which match the pattern - let request: SQLRequest = { - let hasValidPattern: Bool = (pattern != nil && pattern?.rawPattern != "\"\"*") - let targetJoin: SQL = { - guard hasValidPattern else { return "FROM \(Profile.self)" } - - return """ - FROM \(profileFullTextSearch) - JOIN \(Profile.self) ON ( - \(Profile.self).rowid = \(profileFullTextSearch).rowid AND ( - \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR - \(prefixesLiteral) - ) - ) - """ - }() - let targetWhere: SQL = { - guard let pattern: FTS5Pattern = pattern, pattern.rawPattern != "\"\"*" else { - return """ - WHERE ( - \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR - \(prefixesLiteral) - ) - """ - } - - let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)") - - return "WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'" - }() - - switch threadVariant { - case .contact: - return SQLRequest(""" - SELECT - \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")) - - \(targetJoin) - \(targetWhere) AND ( - \(SQL("\(profile[.id]) = \(threadId)")) OR - \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) - ) - ORDER BY \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC - """) - - case .legacyGroup, .group: - return SQLRequest(""" - SELECT - \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")) - - \(targetJoin) - JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND - \(groupMember[.profileId]) = \(profile[.id]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) - ) - \(targetWhere) - GROUP BY \(profile[.id]) - ORDER BY - \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, - IFNULL(\(profile[.nickname]), \(profile[.name])) ASC - """) - - case .community: - return SQLRequest(""" - SELECT - \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")), - \(openGroup[.server]) AS \(MentionInfo.Columns.openGroupServer), - \(openGroup[.roomToken]) AS \(MentionInfo.Columns.openGroupRoomToken), - MAX(\(interaction[.timestampMs])) -- Want the newest interaction (for sorting) - - \(targetJoin) - JOIN \(Interaction.self) ON ( - \(SQL("\(interaction[.threadId]) = \(threadId)")) AND - \(interaction[.authorId]) = \(profile[.id]) - ) - JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)")) - \(targetWhere) - GROUP BY \(profile[.id]) - ORDER BY - \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, - \(interaction[.timestampMs].desc) - LIMIT 20 - """) - } - }() - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(MentionInfo.self, [ - .profile: adapters[0] - ]) - } - } -} diff --git a/SessionMessagingKit/Shared Models/MessageInputTypes.swift b/SessionMessagingKit/Shared Models/MessageInputTypes.swift deleted file mode 100644 index 3e5769615d..0000000000 --- a/SessionMessagingKit/Shared Models/MessageInputTypes.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public enum MessageInputTypes: Equatable { - case all - case textOnly - case none -} diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 1b6164f6c2..7ad8db995e 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import UniformTypeIdentifiers import GRDB import DifferenceKit import SessionUIKit @@ -12,23 +13,6 @@ fileprivate typealias ViewModel = MessageViewModel fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo -fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo - -public struct QuoteViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { - fileprivate static let numberOfColumns: Int = 4 - - public let interactionId: Int64 - public let authorId: String - public let timestampMs: Int64 - public let body: String? - - public init(interactionId: Int64, authorId: String, timestampMs: Int64, body: String?) { - self.interactionId = interactionId - self.authorId = authorId - self.timestampMs = timestampMs - self.body = body - } -} // TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { @@ -65,7 +49,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case isSenderModeratorOrAdmin case isTypingIndicator case profile - case quotedInfo + case quoteViewModel case linkPreview case linkPreviewAttachment @@ -148,7 +132,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let isSenderModeratorOrAdmin: Bool public let isTypingIndicator: Bool? public let profile: Profile? - public let quotedInfo: QuotedInfo? + public let quoteViewModel: QuoteViewModel? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? @@ -226,7 +210,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, state: Update = .useExisting, // Optimistic outgoing messages mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages profile: Update = .useExisting, - quotedInfo: Update = .useExisting, // Workaround for blinded current user + quoteViewModel: Update = .useExisting, // Workaround for blinded current user attachments: Update<[Attachment]?> = .useExisting, reactionInfo: Update<[ReactionInfo]?> = .useExisting ) -> MessageViewModel { @@ -258,7 +242,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, isTypingIndicator: self.isTypingIndicator, profile: profile.or(self.profile), - quotedInfo: quotedInfo.or(self.quotedInfo), + quoteViewModel: quoteViewModel.or(self.quoteViewModel), linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserSessionId: self.currentUserSessionId, @@ -474,7 +458,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, isTypingIndicator: self.isTypingIndicator, profile: (self.profile?.id == currentUserProfile.id ? currentUserProfile : self.profile), - quotedInfo: self.quotedInfo, + quoteViewModel: self.quoteViewModel, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserSessionId: self.currentUserSessionId, @@ -645,67 +629,6 @@ public extension MessageViewModel { } } -// MARK: - QuotedInfo - -public extension MessageViewModel { - struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Hashable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case interactionId - case authorId - case timestampMs - case body - case attachment - case quotedInteractionId - case quotedInteractionVariant - } - - public let rowId: Int64 - public let interactionId: Int64 - public let authorId: String - public let timestampMs: Int64 - public let body: String? - public let attachment: Attachment? - public let quotedInteractionId: Int64 - public let quotedInteractionVariant: Interaction.Variant - - // MARK: - Identifiable - - public var id: String { "quote-\(interactionId)-attachment_\(attachment?.id ?? "None")" } - - // MARK: - Initialization - - public init(previewBody: String) { - self.body = previewBody - - /// This is an preview version so none of these values matter - self.rowId = -1 - self.interactionId = -1 - self.authorId = "" - self.timestampMs = 0 - self.attachment = nil - self.quotedInteractionId = -1 - self.quotedInteractionVariant = .standardOutgoing - } - - public init?(replyModel: QuotedReplyModel?) { - guard let model: QuotedReplyModel = replyModel else { return nil } - - self.authorId = model.authorId - self.timestampMs = model.timestampMs - self.body = model.body - self.attachment = model.attachment - - /// This is an optimistic version so none of these values exist yet - self.rowId = -1 - self.interactionId = -1 - self.quotedInteractionId = -1 - self.quotedInteractionVariant = .standardOutgoing - } - } -} - // MARK: - Convenience Initialization public extension MessageViewModel { @@ -719,7 +642,7 @@ public extension MessageViewModel { timestampMs: Int64 = Int64.max, receivedAtTimestampMs: Int64 = Int64.max, body: String? = nil, - quotedInfo: QuotedInfo? = nil, + quoteViewModel: QuoteViewModel? = nil, cellType: CellType = .typingIndicator, isTypingIndicator: Bool? = nil, isLast: Bool = true, @@ -762,7 +685,7 @@ public extension MessageViewModel { self.isSenderModeratorOrAdmin = false self.isTypingIndicator = isTypingIndicator self.profile = nil - self.quotedInfo = quotedInfo + self.quoteViewModel = quoteViewModel self.linkPreview = nil self.linkPreviewAttachment = nil self.currentUserSessionId = "" @@ -809,7 +732,7 @@ public extension MessageViewModel { state: Interaction.State = .sending, isSenderModeratorOrAdmin: Bool, currentUserProfile: Profile, - quotedInfo: QuotedInfo?, + quoteViewModel: QuoteViewModel?, linkPreview: LinkPreview?, linkPreviewAttachment: Attachment?, attachments: [Attachment]? @@ -845,7 +768,7 @@ public extension MessageViewModel { self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin self.isTypingIndicator = false self.profile = currentUserProfile - self.quotedInfo = quotedInfo + self.quoteViewModel = quoteViewModel self.linkPreview = linkPreview self.linkPreviewAttachment = linkPreviewAttachment self.currentUserSessionId = currentUserProfile.id @@ -1263,14 +1186,163 @@ public extension MessageViewModel.TypingIndicatorInfo { } } -// MARK: --QuotedInfo +// MARK: - QuoteViewModel + +extension QuoteViewModel: @retroactive FetchableRecordWithRowId, @retroactive Decodable, @retroactive Identifiable, Differentiable, @retroactive ColumnExpressible { + fileprivate static let numberOfColumns: Int = 4 + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case interactionId + case authorId + case timestampMs + case quotedInteractionId + case quotedInteractionVariant + case quotedText + case quotedAttachment + } + + // MARK: - Identifiable + + public var id: String { + "quote-\(interactionId.map { "\($0)" } ?? "nil")-attachment_\(quotedAttachmentInfo?.id ?? "None")" + } + + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + var quotedAttachmentInfo: AttachmentInfo? + var interactionIsDeleted: Bool = false + + if + let attachment: Attachment = try container.decodeIfPresent(Attachment.self, forKey: .quotedAttachment), + let utType: UTType = UTType(sessionMimeType: attachment.contentType) + { + quotedAttachmentInfo = AttachmentInfo( + id: attachment.id, + utType: utType, + isVoiceMessage: (attachment.variant == .voiceMessage), + downloadUrl: attachment.downloadUrl, + sourceFilename: attachment.sourceFilename, + thumbnailSource: nil /// Intentionally `nil`, should be set via the `with` function below in the UI + ) + } + + if let variant: Interaction.Variant = try container.decodeIfPresent(Interaction.Variant.self, forKey: .quotedInteractionVariant) { + interactionIsDeleted = variant.isDeletedMessage + } + + self = QuoteViewModel( + mode: .regular, + direction: .outgoing, + currentUserSessionIds: [], + rowId: try container.decode(Int64.self, forKey: .rowId), + interactionId: try container.decode(Int64.self, forKey: .interactionId), + authorId: try container.decode(String.self, forKey: .authorId), + timestampMs: try container.decode(Int64.self, forKey: .timestampMs), + quotedInteractionId: try container.decode(Int64.self, forKey: .quotedInteractionId), + quotedInteractionIsDeleted: interactionIsDeleted, + quotedText: try container.decodeIfPresent(String.self, forKey: .quotedText), + quotedAttachmentInfo: quotedAttachmentInfo, + displayNameRetriever: { _, _ in nil } + ) + } + + public func with( + thumbnailSource: ImageDataManager.DataSource?, + displayNameRetriever: @escaping (String, Bool) -> String? + ) -> QuoteViewModel { + return QuoteViewModel( + mode: mode, + direction: direction, + currentUserSessionIds: currentUserSessionIds, + rowId: rowId, + interactionId: interactionId, + authorId: authorId, + timestampMs: timestampMs, + quotedInteractionId: quotedInteractionId, + quotedInteractionIsDeleted: quotedInteractionIsDeleted, + quotedText: quotedText, + quotedAttachmentInfo: quotedAttachmentInfo.map { + AttachmentInfo( + id: $0.id, + utType: $0.utType, + isVoiceMessage: $0.isVoiceMessage, + downloadUrl: $0.downloadUrl, + sourceFilename: $0.sourceFilename, + thumbnailSource: thumbnailSource + ) + }, + displayNameRetriever: displayNameRetriever + ) + } +} +//TODO: Need to test that this actually works +//public extension MessageViewModel { +// struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Hashable, ColumnExpressible { +// public typealias Columns = CodingKeys +// public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { +// case rowId +// case interactionId +// case authorId +// case timestampMs +// case body +// case attachment +// case quotedInteractionId +// case quotedInteractionVariant +// } +// +// public let rowId: Int64 +// public let interactionId: Int64 +// public let authorId: String +// public let timestampMs: Int64 +// public let body: String? +// public let attachment: Attachment? +// public let quotedInteractionId: Int64 +// public let quotedInteractionVariant: Interaction.Variant +// +// // MARK: - Identifiable +// +// public var id: String { "quote-\(interactionId)-attachment_\(attachment?.id ?? "None")" } +// +// // MARK: - Initialization +// +// public init(previewBody: String) { +// self.body = previewBody +// +// /// This is an preview version so none of these values matter +// self.rowId = -1 +// self.interactionId = -1 +// self.authorId = "" +// self.timestampMs = 0 +// self.attachment = nil +// self.quotedInteractionId = -1 +// self.quotedInteractionVariant = .standardOutgoing +// } +// +// public init?(replyModel: QuotedReplyModel?) { +// guard let model: QuotedReplyModel = replyModel else { return nil } +// +// self.authorId = model.authorId +// self.timestampMs = model.timestampMs +// self.body = model.body +// self.attachment = model.attachment +// +// /// This is an optimistic version so none of these values exist yet +// self.rowId = -1 +// self.interactionId = -1 +// self.quotedInteractionId = -1 +// self.quotedInteractionVariant = .standardOutgoing +// } +// } +//} -public extension MessageViewModel.QuotedInfo { +public extension QuoteViewModel { static func baseQuery( userSessionId: SessionId, currentUserSessionIds: Set - ) -> ((SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters -> AdaptedFetchRequest> in + ) -> ((SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters -> AdaptedFetchRequest> in let quote: TypedTableAlias = TypedTableAlias() let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( @@ -1289,17 +1361,17 @@ public extension MessageViewModel.QuotedInfo { """ }() - let numColumnsBeforeLinkedRecords: Int = 5 - let request: SQLRequest = """ + let numColumnsBeforeLinkedRecords: Int = 7 + let request: SQLRequest = """ SELECT - \(quote[.rowId]) AS \(QuotedInfo.Columns.rowId), - \(quote[.interactionId]) AS \(QuotedInfo.Columns.interactionId), - \(quote[.authorId]) AS \(QuotedInfo.Columns.authorId), - \(quote[.timestampMs]) AS \(QuotedInfo.Columns.timestampMs), - \(quoteInteraction[.body]) AS \(QuotedInfo.Columns.body), - \(attachment.allColumns), - \(quoteInteraction[.id]) AS \(QuotedInfo.Columns.quotedInteractionId), - \(quoteInteraction[.variant]) AS \(QuotedInfo.Columns.quotedInteractionVariant) + \(quote[.rowId]) AS \(QuoteViewModel.Columns.rowId), + \(quote[.interactionId]) AS \(QuoteViewModel.Columns.interactionId), + \(quote[.authorId]) AS \(QuoteViewModel.Columns.authorId), + \(quote[.timestampMs]) AS \(QuoteViewModel.Columns.timestampMs), + \(quoteInteraction[.id]) AS \(QuoteViewModel.Columns.quotedInteractionId), + \(quoteInteraction[.variant]) AS \(QuoteViewModel.Columns.quotedInteractionVariant), + \(quoteInteraction[.body]) AS \(QuoteViewModel.Columns.quotedText), + \(attachment.allColumns) FROM \(Quote.self) JOIN \(quoteInteraction) ON ( \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( @@ -1335,8 +1407,8 @@ public extension MessageViewModel.QuotedInfo { Attachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter.with(QuotedInfo.self, [ - .attachment: adapters[1] + return ScopeAdapter.with(QuoteViewModel.self, [ + .quotedAttachment: adapters[1] ]) } } @@ -1351,45 +1423,48 @@ public extension MessageViewModel.QuotedInfo { """ } - static func createReferencedRowIdsRetriever() -> (([Int64], DataCache) -> [Int64]) { + static func createReferencedRowIdsRetriever() -> (([Int64], DataCache) -> [Int64]) { return { pagedRowIds, dataCache -> [Int64] in - dataCache.values.compactMap { quotedInfo in + dataCache.values.compactMap { viewModel in guard - pagedRowIds.contains(quotedInfo.quotedInteractionId) || - pagedRowIds.contains(quotedInfo.interactionId) + let interactionId: Int64 = viewModel.interactionId, ( + pagedRowIds.contains(viewModel.quotedInteractionId) || + pagedRowIds.contains(interactionId) + ) else { return nil } - return quotedInfo.rowId + return viewModel.rowId } } } - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { + static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { return { dataCache, pagedDataCache -> DataCache in var updatedPagedDataCache: DataCache = pagedDataCache // Update changed records - dataCache.values.forEach { quoteInfo in + dataCache.values.forEach { quoteViewModel in guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.interactionId], + let interactionId: Int64 = quoteViewModel.interactionId, + let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] else { return } - switch quoteInfo.quotedInteractionVariant.isDeletedMessage { + switch quoteViewModel.quotedInteractionIsDeleted { // If the original message wasn't deleted and the quote contains some of it's content // then remove that content from the quote case false: updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(quotedInfo: .set(to: quoteInfo)) + dataToUpdate.with(quoteViewModel: .set(to: quoteViewModel)) ) // If the original message was deleted and the quote contains some of it's content // then remove that content from the quote case true: - guard dataToUpdate.quotedInfo != nil else { return } + guard dataToUpdate.quoteViewModel != nil else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(quotedInfo: .set(to: nil)) + dataToUpdate.with(quoteViewModel: .set(to: nil)) ) } } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 90e986ca60..17d01726f9 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -96,29 +96,6 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D case isContactApproved } - public struct MessageInputState: Equatable { - public let allowedInputTypes: MessageInputTypes - public let message: String? - public let accessibility: Accessibility? - public let messageAccessibility: Accessibility? - - public static var all: MessageInputState = MessageInputState(allowedInputTypes: .all) - - // MARK: - Initialization - - init( - allowedInputTypes: MessageInputTypes, - message: String? = nil, - accessibility: Accessibility? = nil, - messageAccessibility: Accessibility? = nil - ) { - self.allowedInputTypes = allowedInputTypes - self.message = message - self.accessibility = accessibility - self.messageAccessibility = messageAccessibility - } - } - public var differenceIdentifier: String { threadId } public var id: String { threadId } @@ -267,10 +244,10 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D return Date(timeIntervalSince1970: TimeInterval(Double(interactionTimestampMs) / 1000)) } - public var messageInputState: MessageInputState { - guard !threadIsNoteToSelf else { return MessageInputState(allowedInputTypes: .all) } + public var messageInputState: InputView.InputState { + guard !threadIsNoteToSelf else { return InputView.InputState(allowedInputTypes: .all) } guard threadIsBlocked != true else { - return MessageInputState( + return InputView.InputState( allowedInputTypes: .none, message: "blockBlockedDescription".localized(), messageAccessibility: Accessibility( @@ -280,17 +257,22 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D } if threadVariant == .community && threadCanWrite == false { - return MessageInputState( + return InputView.InputState( allowedInputTypes: .none, message: "permissionsWriteCommunity".localized() ) } - return MessageInputState( - allowedInputTypes: (threadRequiresApproval == false && threadIsMessageRequest == false ? - .all : - .textOnly - ) + /// Attachments shouldn't be allowed for message requests or if uploads are disabled + let finalInputType: InputView.InputTypes + + switch (threadRequiresApproval, threadIsMessageRequest, threadCanUpload) { + case (false, false, true): finalInputType = .all + default: finalInputType = .textOnly + } + + return InputView.InputState( + allowedInputTypes: finalInputType ) } diff --git a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift new file mode 100644 index 0000000000..df241007d2 --- /dev/null +++ b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift @@ -0,0 +1,205 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public extension MentionSelectionView.ViewModel { + static func mentions( + profiles: [Profile], + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set, + adminModMembers: [GroupMember], + using dependencies: Dependencies + ) -> [MentionSelectionView.ViewModel] { + let adminModIds: Set = Set(adminModMembers.map { $0.profileId }) + + return profiles.compactMap { profile -> MentionSelectionView.ViewModel? in + guard let info: ProfilePictureView.Info = ProfilePictureView.Info.generateInfoFrom( + size: MentionSelectionView.profilePictureViewSize, + publicKey: profile.id, + threadVariant: .contact, /// Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: profile, + profileIcon: (adminModIds.contains(profile.id) ? .crown : .none), + using: dependencies + ).front else { return nil } + + return MentionSelectionView.ViewModel( + profileId: profile.id, + displayName: profile.displayNameForMention( + for: threadVariant, + currentUserSessionIds: currentUserSessionIds + ), + profilePictureInfo: info + ) + } + } + + static func mentions( + for query: String = "", + threadId: String, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set, + communityInfo: (server: String, roomToken: String)?, + using dependencies: Dependencies + ) async throws -> [MentionSelectionView.ViewModel] { + let (profiles, adminModMembers): ([Profile], [GroupMember]) = try await dependencies[singleton: .storage].readAsync { db in + let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) + let capabilities: Set = (threadVariant != .community ? + nil : + try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == communityInfo?.server) + .asRequest(of: Capability.Variant.self) + .fetchSet(db) + ) + .defaulting(to: []) + let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? + [.blinded15, .blinded25] : + [.standard] + ) + let profiles: [Profile] = try mentionsQuery( + threadId: threadId, + threadVariant: threadVariant, + targetPrefixes: targetPrefixes, + currentUserSessionIds: currentUserSessionIds, + pattern: pattern + ).fetchAll(db) + + /// If it's not a community then no need to determine admin/moderator status + guard threadVariant == .community, let communityId: String = communityInfo.map({ OpenGroup.idFor(roomToken: $0.roomToken, server: $0.server) }) else { + return (profiles, []) + } + + let adminModMembers: [GroupMember] = try dependencies[singleton: .openGroupManager].membersWhere( + db, + currentUserSessionIds: currentUserSessionIds, + .groupIds([communityId]), + .publicKeys(profiles.map { $0.id }), + .roles([.moderator, .admin]) + ) + + return (profiles, adminModMembers) + } + + return mentions( + profiles: profiles, + threadVariant: threadVariant, + currentUserSessionIds: currentUserSessionIds, + adminModMembers: adminModMembers, + using: dependencies + ) + } + + // stringlint:ignore_contents + private static func mentionsQuery( + threadId: String, + threadVariant: SessionThread.Variant, + targetPrefixes: [SessionId.Prefix], + currentUserSessionIds: Set, + pattern: FTS5Pattern? + ) -> SQLRequest { + let profile: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let prefixesLiteral: SQLExpression = targetPrefixes + .map { prefix in + SQL( + """ + ( + \(profile[.id]) > '\(SQL(stringLiteral: "\(prefix.rawValue)"))' AND + \(profile[.id]) < '\(SQL(stringLiteral: "\(prefix.endOfRangeString)"))' + ) + """) + } + .joined(operator: .or) + let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) + + /// The query needs to differ depending on the thread variant because the behaviour should be different: + /// + /// **Contact:** We should show the profile directly (filtered out if the pattern doesn't match) + /// **Group:** We should show all profiles within the group, filtered by the pattern + /// **Community:** We should show only the 20 most recent profiles which match the pattern + let hasValidPattern: Bool = (pattern != nil && pattern?.rawPattern != "\"\"*") + let targetJoin: SQL = { + guard hasValidPattern else { return "FROM \(Profile.self)" } + + return """ + FROM \(profileFullTextSearch) + JOIN \(Profile.self) ON ( + \(Profile.self).rowid = \(profileFullTextSearch).rowid AND ( + \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR + \(prefixesLiteral) + ) + ) + """ + }() + let targetWhere: SQL = { + guard let pattern: FTS5Pattern = pattern, pattern.rawPattern != "\"\"*" else { + return """ + WHERE ( + \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR + \(prefixesLiteral) + ) + """ + } + + let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)") + + return "WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'" + }() + + switch threadVariant { + case .contact: + return SQLRequest(""" + SELECT \(Profile.self).*, + \(targetJoin) + \(targetWhere) AND ( + \(SQL("\(profile[.id]) = \(threadId)")) OR + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) + ) + ORDER BY \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC + """) + + case .legacyGroup, .group: + return SQLRequest(""" + SELECT \(Profile.self).*, + \(targetJoin) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND + \(groupMember[.profileId]) = \(profile[.id]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) + ) + \(targetWhere) + GROUP BY \(profile[.id]) + ORDER BY + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, + IFNULL(\(profile[.nickname]), \(profile[.name])) ASC + """) + + case .community: + return SQLRequest(""" + SELECT + \(Profile.self).*, + MAX(\(interaction[.timestampMs])) -- Want the newest interaction (for sorting) + + \(targetJoin) + JOIN \(Interaction.self) ON ( + \(SQL("\(interaction[.threadId]) = \(threadId)")) AND + \(interaction[.authorId]) = \(profile[.id]) + ) + JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)")) + \(targetWhere) + GROUP BY \(profile[.id]) + ORDER BY + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, + \(interaction[.timestampMs].desc) + LIMIT 20 + """) + } + } +} diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 1806635be0..ddbfe2b31e 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -10,12 +10,12 @@ public extension ProfilePictureView { threadVariant: SessionThread.Variant, displayPictureUrl: String?, profile: Profile?, - profileIcon: ProfileIcon = .none, + profileIcon: Info.ProfileIcon = .none, additionalProfile: Profile? = nil, - additionalProfileIcon: ProfileIcon = .none, + additionalProfileIcon: Info.ProfileIcon = .none, using dependencies: Dependencies ) { - let (info, additionalInfo): (Info?, Info?) = ProfilePictureView.getProfilePictureInfo( + let (info, additionalInfo): (front: Info?, back: Info?) = Info.generateInfoFrom( size: self.size, publicKey: publicKey, threadVariant: threadVariant, @@ -31,8 +31,10 @@ public extension ProfilePictureView { update(info, additionalInfo: additionalInfo) } - - static func getProfilePictureInfo( +} + +public extension ProfilePictureView.Info { + static func generateInfoFrom( size: Size, publicKey: String, threadVariant: SessionThread.Variant, @@ -42,7 +44,7 @@ public extension ProfilePictureView { additionalProfile: Profile? = nil, additionalProfileIcon: ProfileIcon = .none, using dependencies: Dependencies - ) -> (Info?, Info?) { + ) -> (front: ProfilePictureView.Info?, back: ProfilePictureView.Info?) { let explicitPath: String? = try? dependencies[singleton: .displayPictureManager].path( for: displayPictureUrl ) @@ -53,7 +55,7 @@ public extension ProfilePictureView { case (.some(let path), true, _, .legacyGroup), (.some(let path), true, _, .group): fallthrough case (.some(let path), true, _, .community): /// If we are given an explicit `displayPictureUrl` then only use that - return (Info( + return (ProfilePictureView.Info( source: .url(URL(fileURLWithPath: path)), animationBehaviour: .generic(true), icon: profileIcon @@ -62,7 +64,7 @@ public extension ProfilePictureView { case (.some(let path), true, _, _): /// If we are given an explicit `displayPictureUrl` then only use that return ( - Info( + ProfilePictureView.Info( source: .url(URL(fileURLWithPath: path)), animationBehaviour: ProfilePictureView.animationBehaviour(from: profile, using: dependencies), icon: profileIcon @@ -72,7 +74,7 @@ public extension ProfilePictureView { case (_, _, _, .community): return ( - Info( + ProfilePictureView.Info( source: { switch size { case .navigation, .message: return .image("SessionWhite16", #imageLiteral(resourceName: "SessionWhite16")) @@ -117,7 +119,7 @@ public extension ProfilePictureView { }() return ( - Info( + ProfilePictureView.Info( source: source, animationBehaviour: ProfilePictureView.animationBehaviour(from: profile, using: dependencies), icon: profileIcon @@ -140,14 +142,14 @@ public extension ProfilePictureView { return ImageDataManager.DataSource.url(URL(fileURLWithPath: path)) }() - return Info( + return ProfilePictureView.Info( source: source, animationBehaviour: ProfilePictureView.animationBehaviour(from: other, using: dependencies), icon: additionalProfileIcon ) } .defaulting( - to: Info( + to: ProfilePictureView.Info( source: .image("ic_user_round_fill", UIImage(named: "ic_user_round_fill")), animationBehaviour: .generic(false), renderingMode: .alwaysTemplate, @@ -182,7 +184,7 @@ public extension ProfilePictureView { }() return ( - Info( + ProfilePictureView.Info( source: source, animationBehaviour: ProfilePictureView.animationBehaviour(from: profile, using: dependencies), icon: profileIcon), @@ -210,17 +212,17 @@ public extension ProfilePictureView { public extension ProfilePictureSwiftUI { init?( - size: ProfilePictureView.Size, + size: ProfilePictureView.Info.Size, publicKey: String, threadVariant: SessionThread.Variant, displayPictureUrl: String?, profile: Profile?, - profileIcon: ProfilePictureView.ProfileIcon = .none, + profileIcon: ProfilePictureView.Info.ProfileIcon = .none, additionalProfile: Profile? = nil, - additionalProfileIcon: ProfilePictureView.ProfileIcon = .none, + additionalProfileIcon: ProfilePictureView.Info.ProfileIcon = .none, using dependencies: Dependencies ) { - let (info, additionalInfo) = ProfilePictureView.getProfilePictureInfo( + let (info, additionalInfo) = ProfilePictureView.Info.generateInfoFrom( size: size, publicKey: publicKey, threadVariant: threadVariant, diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index 17e0ea1b7e..a950ad1c04 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -38,14 +38,12 @@ public class SessionProState: SessionProManagerType { @discardableResult @MainActor public func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { return false } - beforePresented?() let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( delegate: dependencies[singleton: .sessionProState], diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index f1dae8efd7..fc0d3256e7 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -681,6 +681,7 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { var maxFileSize: UInt { Network.maxFileSize } var isStorageValid: Bool { dependencies[singleton: .storage].isValid } + var isRTL: Bool { Dependencies.isRTL } // MARK: - Initialization diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 5c55187216..65b3797a6d 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -220,6 +220,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView threadId: self.viewModel.viewData[indexPath.row].threadId, threadVariant: self.viewModel.viewData[indexPath.row].threadVariant, attachments: attachments, + quoteDraft: nil, approvalDelegate: self, disableLinkPreviewImageDownload: ( self.viewModel.viewData[indexPath.row].threadCanUpload != true diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 206ca22f7b..b29a2c6bc2 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -4,6 +4,7 @@ import Foundation import UniformTypeIdentifiers import GRDB import DifferenceKit +import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit import SessionUtilitiesKit diff --git a/SessionTests/Session.xctestplan b/SessionTests/Session.xctestplan index a9c2927686..fb0e4db9f4 100644 --- a/SessionTests/Session.xctestplan +++ b/SessionTests/Session.xctestplan @@ -70,8 +70,8 @@ "parallelizable" : true, "target" : { "containerPath" : "container:Session.xcodeproj", - "identifier" : "FD71160828D00BAE00B47552", - "name" : "SessionTests" + "identifier" : "FDB5DAF92A981C42002C8721", + "name" : "SessionNetworkingKitTests" } }, { @@ -86,16 +86,24 @@ "parallelizable" : true, "target" : { "containerPath" : "container:Session.xcodeproj", - "identifier" : "FDB5DAF92A981C42002C8721", - "name" : "SessionNetworkingKitTests" + "identifier" : "FD83B9AE27CF200A005E1583", + "name" : "SessionUtilitiesKitTests" } }, { "parallelizable" : true, "target" : { "containerPath" : "container:Session.xcodeproj", - "identifier" : "FD83B9AE27CF200A005E1583", - "name" : "SessionUtilitiesKitTests" + "identifier" : "FD71160828D00BAE00B47552", + "name" : "SessionTests" + } + }, + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Session.xcodeproj", + "identifier" : "FD9E26B72EA72D3E00404C7F", + "name" : "SessionUIKitTests" } } ], diff --git a/Session/Conversations/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift similarity index 57% rename from Session/Conversations/Input View/InputView.swift rename to SessionUIKit/Components/Input View/InputView.swift index 38fd63053b..55a9d7568f 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -1,57 +1,93 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import UniformTypeIdentifiers import Combine -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit -import SignalUtilitiesKit -final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { +public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { + public enum InputTypes: Equatable { + case all + case textOnly + case none + } + + public struct InputState: Equatable { + public let allowedInputTypes: InputTypes + public let message: String? + public let accessibility: Accessibility? + public let messageAccessibility: Accessibility? + + public static var all: InputState = InputState(allowedInputTypes: .all) + + // MARK: - Initialization + + init( + allowedInputTypes: InputTypes, + message: String? = nil, + accessibility: Accessibility? = nil, + messageAccessibility: Accessibility? = nil + ) { + self.allowedInputTypes = allowedInputTypes + self.message = message + self.accessibility = accessibility + self.messageAccessibility = messageAccessibility + } + } + // MARK: - Variables private static let linkPreviewViewInset: CGFloat = 6 private static let thresholdForCharacterLimit: Int = 200 private var disposables: Set = Set() - private let dependencies: Dependencies - private let threadVariant: SessionThread.Variant + private let dataManager: ImageDataManagerType + private let displayNameRetriever: (String, Bool) -> String? private weak var delegate: InputViewDelegate? private var sessionProState: SessionProManagerType? - var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } - var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + public var quoteDraft: QuoteViewModel? { didSet { handleQuoteDraftChanged() } } + public var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? private var linkPreviewLoadTask: Task? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) - private lazy var linkPreviewView: LinkPreviewView = { - let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) + private lazy var linkPreviewContainerView: UIView = { + let result: UIView = UIView() + result.isHidden = true + result.addSubview(linkPreviewView) + linkPreviewView.pin(.top, to: .top, of: result, withInset: 10) + linkPreviewView.pin(.leading, to: .leading, of: result, withInset: (12 + InputView.linkPreviewViewInset)) + linkPreviewView.pin(.trailing, to: .trailing, of: result, withInset: -14) + linkPreviewView.pin(.bottom, to: .bottom, of: result, withInset: -4) - return LinkPreviewView(maxWidth: maxWidth, using: dependencies) { [weak self] in - self?.linkPreviewInfo = nil - self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - } + return result }() + + private lazy var linkPreviewView: LinkPreviewView = LinkPreviewView { [weak self] in + self?.linkPreviewInfo = nil + self?.linkPreviewContainerView.isHidden = true + } - var text: String { + @MainActor public var text: String { get { inputTextView.text ?? "" } set { inputTextView.text = newValue } } - var selectedRange: NSRange { + @MainActor var selectedRange: NSRange { get { inputTextView.selectedRange } set { inputTextView.selectedRange = newValue } } - var inputState: SessionThreadViewModel.MessageInputState = .all { - didSet { - setMessageInputState(inputState) - } + @MainActor var inputState: InputState = .all { + didSet { setMessageInputState(inputState) } } - override var intrinsicContentSize: CGSize { CGSize.zero } + public override var intrinsicContentSize: CGSize { CGSize.zero } var lastSearchedText: String? { nil } + + public var isInputFirstResponder: Bool { + inputTextView.isFirstResponder + } // MARK: - UI @@ -72,17 +108,17 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M return result }() - private var bottomStackView: UIStackView? - private lazy var attachmentsButton: ExpandingAttachmentsButton = { - let result = ExpandingAttachmentsButton(delegate: delegate) + public lazy var attachmentsButton: InputViewButton = { + let result = InputViewButton(icon: #imageLiteral(resourceName: "ic_plus_24"), delegate: self) result.accessibilityLabel = "Attachments button" result.accessibilityIdentifier = "Attachments button" result.isAccessibilityElement = true return result }() + public lazy var attachmentsButtonContainer = InputViewButton.container(for: attachmentsButton) - private lazy var voiceMessageButton: InputViewButton = { + public lazy var voiceMessageButton: InputViewButton = { let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) result.accessibilityLabel = "New voice message" result.accessibilityIdentifier = "New voice message" @@ -91,7 +127,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M return result }() - private lazy var sendButton: InputViewButton = { + public lazy var sendButton: InputViewButton = { let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) result.isHidden = true result.accessibilityIdentifier = "Send message button" @@ -100,10 +136,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M return result }() - private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) + private lazy var voiceMessageButtonContainer = InputViewButton.container(for: voiceMessageButton) + + private lazy var bottomStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + attachmentsButtonContainer, + inputTextView, + InputViewButton.container(for: sendButton) + ]) + result.axis = .horizontal + result.spacing = Values.smallSpacing + result.alignment = .center + result.isLayoutMarginsRelativeArrangement = true + + let adjustment: CGFloat = (InputViewButton.expandedSize - InputViewButton.size) / 2 + result.layoutMargins = UIEdgeInsets( + top: 2, + leading: Values.mediumSpacing - adjustment, + bottom: 2, + trailing: Values.mediumSpacing - adjustment + ) + + return result + }() private lazy var mentionsView: MentionSelectionView = { - let result: MentionSelectionView = MentionSelectionView(using: dependencies) + let result: MentionSelectionView = MentionSelectionView(dataManager: dataManager) result.delegate = self return result @@ -114,6 +172,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M result.accessibilityLabel = "Mentions list" result.accessibilityIdentifier = "Mentions list" result.alpha = 0 + result.isHidden = true let backgroundView = UIView() backgroundView.themeBackgroundColor = .backgroundSecondary @@ -129,8 +188,54 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M blurView?.effect = UIBlurEffect(style: theme.blurStyle) } + result.addSubview(mentionsView) + mentionsView.pin(to: result) + + return result + }() + + private lazy var separator: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .borderSeparator + result.set(.height, to: Values.separatorThickness) + + return result + }() + + private lazy var quoteViewContainerView: UIView = { + let result: UIView = UIView() + result.isHidden = true + + result.addSubview(quoteView) + quoteView.pin(.top, to: .top, of: result, withInset: 12) + quoteView.pin(.leading, to: .leading, of: result, withInset: (12 + 6)) + quoteView.pin(.trailing, to: .trailing, of: result, withInset: -11) + quoteView.pin(.bottom, to: .bottom, of: result, withInset: -6) + return result }() + + private lazy var quoteView: QuoteView = QuoteView( + viewModel: QuoteViewModel( + mode: .draft, + direction: .outgoing, + currentUserSessionIds: [], + rowId: 0, + interactionId: nil, + authorId: "", + timestampMs: 0, + quotedInteractionId: 0, + quotedInteractionIsDeleted: false, + quotedText: nil, + quotedAttachmentInfo: nil, + displayNameRetriever: displayNameRetriever + ), + dataManager: dataManager, + onCancel: { [weak self] in + self?.quoteDraft = nil + self?.quoteViewContainerView.isHidden = true + } + ) private lazy var inputTextView: InputTextView = { // HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't @@ -190,24 +295,53 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: .small) - result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro + // TODO: Need to add this back +// result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result }() - - private lazy var additionalContentContainer = UIView() - public var isInputFirstResponder: Bool { - inputTextView.isFirstResponder - } + private lazy var mainStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + mentionsViewContainer, + separator, + linkPreviewContainerView, + quoteViewContainerView, + bottomStackView + ]) + result.axis = .vertical + result.alignment = .fill + result.distribution = .fill + + return result + }() + + public var inputContainerForBackground: UIView { mainStackView } + + private lazy var extraStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + mentionsViewContainer, + mainStackView + ]) + result.axis = .vertical + result.alignment = .fill + result.distribution = .fill + + return result + }() // MARK: - Initialization - init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate, using dependencies: Dependencies) { - self.dependencies = dependencies - self.threadVariant = threadVariant + public init( + delegate: InputViewDelegate, + displayNameRetriever: @escaping (String, Bool) -> String?, + dataManager: ImageDataManagerType, + sessionProState: SessionProManagerType? + ) { + self.dataManager = dataManager self.delegate = delegate - self.sessionProState = dependencies[singleton: .sessionProState] + self.displayNameRetriever = displayNameRetriever + self.sessionProState = sessionProState super.init(frame: CGRect.zero) @@ -243,46 +377,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(swipeGestureRecognizer) - // Background & blur - let backgroundView = UIView() - backgroundView.themeBackgroundColor = .backgroundSecondary - backgroundView.alpha = Values.lowOpacity - addSubview(backgroundView) - backgroundView.pin(to: self) - - let blurView = UIVisualEffectView() - addSubview(blurView) - blurView.pin(to: self) - - ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _, _ in - blurView?.effect = UIBlurEffect(style: theme.blurStyle) - } - - // Separator - let separator = UIView() - separator.themeBackgroundColor = .borderSeparator - separator.set(.height, to: Values.separatorThickness) - 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 - self.bottomStackView = bottomStackView - // 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: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - 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) + addSubview(extraStackView) + extraStackView.pin(to: self) + mentionsViewHeightConstraint.isActive = true // Pro stack view addSubview(proStackView) @@ -295,14 +393,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M disabledInputLabel.pin(.leading, to: .leading, of: inputTextView) disabledInputLabel.pin(.trailing, to: .trailing, of: inputTextView) disabledInputLabel.set(.height, to: InputViewButton.expandedSize) - - // Mentions - insertSubview(mentionsViewContainer, belowSubview: mainStackView) - mentionsViewContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) - mentionsViewContainer.pin(.bottom, to: .top, of: self) - mentionsViewContainer.addSubview(mentionsView) - mentionsView.pin(to: mentionsViewContainer) - mentionsViewHeightConstraint.isActive = true // Voice message button addSubview(voiceMessageButtonContainer) @@ -311,12 +401,12 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // MARK: - Updating - @MainActor func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { + @MainActor public func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { invalidateIntrinsicContentSize() - self.bottomStackView?.alignment = (inputTextView.contentSize.height > inputTextView.minHeight) ? .top : .center + self.bottomStackView.alignment = (inputTextView.contentSize.height > inputTextView.minHeight) ? .top : .center } - @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + @MainActor public func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty sendButton.isHidden = !hasText voiceMessageButtonContainer.isHidden = hasText @@ -328,7 +418,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M @MainActor func updateNumberOfCharactersLeft(_ text: String) { let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( for: text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: dependencies[cache: .libSession].isSessionPro + // TODO: Need to add this back + isSessionPro: false +// isSessionPro: dependencies[cache: .libSession].isSessionPro ) characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary @@ -336,7 +428,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M characterLimitLabelTapGestureRecognizer.isEnabled = (numberOfCharactersLeft < Self.thresholdForCharacterLimit) } - @MainActor func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { + @MainActor public func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { delegate?.didPasteImageDataFromPasteboard(imageData) } @@ -345,31 +437,16 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // URL before removing the quote draft. private func handleQuoteDraftChanged() { - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } linkPreviewInfo = nil + linkPreviewContainerView.isHidden = true - guard let quoteDraftInfo = quoteDraftInfo else { return } - - let hInset: CGFloat = 6 // Slight visual adjustment - - let quoteView: QuoteView = QuoteView( - for: .draft, - authorId: quoteDraftInfo.model.authorId, - quotedText: quoteDraftInfo.model.body, - threadVariant: threadVariant, - currentUserSessionIds: quoteDraftInfo.model.currentUserSessionIds, - direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), - attachment: quoteDraftInfo.model.attachment, - using: dependencies - ) { [weak self] in - self?.quoteDraftInfo = nil + guard let quoteDraft: QuoteViewModel = quoteDraft else { + quoteViewContainerView.isHidden = true + return } - additionalContentContainer.addSubview(quoteView) - quoteView.pin(.leading, to: .leading, of: additionalContentContainer, withInset: hInset) - quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) - quoteView.pin(.trailing, to: .trailing, of: additionalContentContainer, withInset: -hInset) - quoteView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -6) + quoteView.update(viewModel: quoteDraft) + quoteViewContainerView.isHidden = false } private func autoGenerateLinkPreviewIfPossible() { @@ -379,7 +456,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // 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! - DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies] in + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in let areLinkPreviewsEnabled: Bool = dependencies.mutate(cache: .libSession) { cache in cache.get(.areLinkPreviewsEnabled) } @@ -415,61 +493,79 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M guard linkPreviewURL != self.linkPreviewInfo?.url else { return } // Clear content container - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - quoteDraftInfo = nil + quoteDraft = nil + quoteViewContainerView.isHidden = true - // Set the state to loading + // Set the state to loading (but don't show yet) linkPreviewInfo = (url: linkPreviewURL, draft: nil) - linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false, using: dependencies) - - // Add the link preview view - additionalContentContainer.addSubview(linkPreviewView) - linkPreviewView.pin(.leading, to: .leading, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) - linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) - linkPreviewView.pin(.trailing, to: .trailing, of: additionalContentContainer) - linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) + linkPreviewView.update( + with: LinkPreview.LoadingState(), + maxWidth: (mainStackView.bounds.width - InputView.linkPreviewViewInset), + isOutgoing: false, + using: dependencies + ) // Build the link preview linkPreviewLoadTask?.cancel() linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes, dependencies] in - do { - /// Load the draft - let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( - previewUrl: linkPreviewURL, - skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled - using: dependencies - ) - try Task.checkCancellation() - - await MainActor.run { [weak self] in - guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + /// Wait for a short period before showing the link preview UI (this is to avoid a situation where an invalid URL shows + /// the loading state very briefly before it disappears + group.addTask { [weak self] in + try await Task.sleep(for: .milliseconds(50)) - linkPreviewInfo = (url: linkPreviewURL, draft: draft) - linkPreviewView.update( - with: LinkPreview.DraftState(linkPreviewDraft: draft), - isOutgoing: false, - using: dependencies - ) - setNeedsLayout() - layoutIfNeeded() + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewContainerView.isHidden = false + } } - } - catch { - await MainActor.run { [weak self] in - guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete - - linkPreviewInfo = nil - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - setNeedsLayout() - layoutIfNeeded() + group.addTask { [weak self] in + do { + /// Load the draft + let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( + previewUrl: linkPreviewURL, + skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled + using: dependencies + ) + try Task.checkCancellation() + + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewInfo = (url: linkPreviewURL, draft: draft) + linkPreviewView.update( + with: LinkPreview.DraftState(linkPreviewDraft: draft), + maxWidth: (mainStackView.bounds.width - InputView.linkPreviewViewInset), + isOutgoing: false, + using: dependencies + ) + linkPreviewContainerView.isHidden = false + setNeedsLayout() + layoutIfNeeded() + } + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewInfo = nil + linkPreviewContainerView.isHidden = true + setNeedsLayout() + layoutIfNeeded() + } + } } + + try? await group.waitForAll() } } } - func setMessageInputState(_ updatedInputState: SessionThreadViewModel.MessageInputState) { + @MainActor func setMessageInputState(_ updatedInputState: InputState) { guard inputState != updatedInputState else { return } self.accessibilityIdentifier = updatedInputState.accessibility?.identifier @@ -485,10 +581,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M voiceMessageButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) UIView.animate(withDuration: 0.3) { [weak self] in - self?.bottomStackView?.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } + self?.bottomStackView.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) - self?.attachmentsButton.mainButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.attachmentsButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) @@ -498,46 +594,23 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } // MARK: - Interaction - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // Needed so that the user can tap the buttons when the expanding attachments button is expanded - let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, - attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] - - if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) { - return buttonContainer - } - - 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 { - // Needed so that the user can tap the buttons when the expanding attachments button is expanded - return true - } - - if mentionsViewContainer.frame.contains(point) { - // Needed so that the user can tap mentions - return true + @MainActor public func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { + if inputViewButton == attachmentsButton { + if inputState.allowedInputTypes != .all { + delegate?.handleDisabledAttachmentButtonTapped() + } + else { + delegate?.handleAttachmentButtonTapped() + } } - - return super.point(inside: point, with: event) - } - - @MainActor func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } if inputViewButton == voiceMessageButton && inputState.allowedInputTypes != .all { delegate?.handleDisabledVoiceMessageButtonTapped() } } - @MainActor func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { + @MainActor public func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { guard inputViewButton == voiceMessageButton else { return } guard inputState.allowedInputTypes == .all else { return } @@ -548,7 +621,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M delegate?.startVoiceMessageRecording() } - @MainActor func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { + @MainActor public func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { guard let voiceMessageRecordingView: VoiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton, @@ -558,7 +631,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M voiceMessageRecordingView.handleLongPressMoved(to: location) } - @MainActor func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) { + @MainActor public func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) { guard let voiceMessageRecordingView: VoiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton, @@ -568,12 +641,12 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M voiceMessageRecordingView.handleLongPressEnded(at: location) } - override func resignFirstResponder() -> Bool { + public override func resignFirstResponder() -> Bool { inputTextView.resignFirstResponder() } @discardableResult - override func becomeFirstResponder() -> Bool { + public override func becomeFirstResponder() -> Bool { inputTextView.becomeFirstResponder() } @@ -596,54 +669,57 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M voiceMessageRecordingView.pin(to: self) self.voiceMessageRecordingView = voiceMessageRecordingView voiceMessageRecordingView.animate() - let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] + let allOtherViews = [ attachmentsButton, sendButton, inputTextView ] 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: { [weak self] _ in - self?.voiceMessageRecordingView?.removeFromSuperview() - self?.voiceMessageRecordingView = nil - }) - } - - func hideMentionsUI() { + let allOtherViews = [ attachmentsButton, sendButton, inputTextView ] UIView.animate( withDuration: 0.25, - animations: { [weak self] in - self?.mentionsViewContainer.alpha = 0 + animations: { + allOtherViews.forEach { $0.alpha = 1 } + self.voiceMessageRecordingView?.alpha = 0 }, completion: { [weak self] _ in - self?.mentionsViewHeightConstraint.constant = 0 - self?.mentionsView.contentOffset = CGPoint.zero + self?.voiceMessageRecordingView?.removeFromSuperview() + self?.voiceMessageRecordingView = nil } ) } - func showMentionsUI( - for candidates: [MentionInfo], - currentUserSessionIds: Set - ) { - mentionsView.currentUserSessionIds = currentUserSessionIds + @MainActor func showMentionsUI(for candidates: [MentionSelectionView.ViewModel]) { mentionsView.candidates = candidates - let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing) + let mentionCellHeight = (ProfilePictureView.Info.Size.message.viewSize + 2 * Values.smallSpacing) mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight + self.mentionsViewContainer.alpha = 0 + self.mentionsViewContainer.isHidden = false layoutIfNeeded() - UIView.animate(withDuration: 0.25) { - self.mentionsViewContainer.alpha = 1 + UIView.animate(withDuration: 0.15) { [weak self] in + self?.mentionsViewContainer.alpha = 1 } } + + @MainActor func hideMentionsUI() { + UIView.animate( + withDuration: 0.15, + animations: { [weak self] in + self?.mentionsViewContainer.alpha = 0 + }, + completion: { [weak self] _ in + self?.mentionsViewContainer.isHidden = true + self?.mentionsViewHeightConstraint.constant = 0 + self?.mentionsView.contentOffset = CGPoint.zero + } + ) + } - @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) { - delegate?.handleMentionSelected(mentionInfo, from: view) + @MainActor public func handleMentionSelected(_ viewModel: MentionSelectionView.ViewModel, from view: MentionSelectionView) { + delegate?.handleMentionSelected(viewModel, from: view) } func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) { @@ -661,29 +737,19 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M @objc private func didSwipeDown() { inputTextView.resignFirstResponder() } - - // MARK: - Convenience - - private func container(for button: InputViewButton) -> UIView { - let result: UIView = 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 { +public protocol InputViewDelegate: VoiceMessageRecordingViewDelegate, AnyObject { @MainActor func showLinkPreviewSuggestionModal() @MainActor func handleSendButtonTapped() @MainActor func handleDisabledInputTapped() + @MainActor func handleAttachmentButtonTapped() + @MainActor func handleDisabledAttachmentButtonTapped() @MainActor func handleDisabledVoiceMessageButtonTapped() @MainActor func handleCharacterLimitLabelTapped() @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) - @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) + @MainActor func handleMentionSelected(_ viewModel: MentionSelectionView.ViewModel, from view: MentionSelectionView) @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) } diff --git a/SessionUIKit/Components/Input View/InputViewButton.swift b/SessionUIKit/Components/Input View/InputViewButton.swift index 69dc850270..3133defe98 100644 --- a/SessionUIKit/Components/Input View/InputViewButton.swift +++ b/SessionUIKit/Components/Input View/InputViewButton.swift @@ -141,12 +141,24 @@ public final class InputViewButton: UIView { backgroundView.themeBackgroundColor = isEnabled ? .inputButton_background : .disabled } + // MARK: - Convenience + + public static func container(for button: InputViewButton) -> UIView { + let result: UIView = UIView() + result.addSubview(button) + result.set(.width, to: InputViewButton.expandedSize) + result.set(.height, to: InputViewButton.expandedSize) + button.center(in: result) + + return result + } + // MARK: - Interaction // We want to detect both taps and long presses public override func touchesBegan(_ touches: Set, with event: UIEvent?) { - guard !isSoftDisabled && isUserInteractionEnabled else { return } + guard !isSoftDisabled && isUserInteractionEnabled && alpha > 0 else { return } UIImpactFeedbackGenerator(style: .heavy).impactOccurred() expand() @@ -158,7 +170,7 @@ public final class InputViewButton: UIView { } public override func touchesMoved(_ touches: Set, with event: UIEvent?) { - guard !isSoftDisabled && isUserInteractionEnabled else { return } + guard !isSoftDisabled && isUserInteractionEnabled && alpha > 0 else { return } if isLongPress { delegate?.handleInputViewButtonLongPressMoved(self, with: touches.first) @@ -166,7 +178,7 @@ public final class InputViewButton: UIView { } public override func touchesEnded(_ touches: Set, with event: UIEvent?) { - guard isUserInteractionEnabled else { return } + guard isUserInteractionEnabled && alpha > 0 else { return } guard !isSoftDisabled else { delegate?.handleInputViewButtonTapped(self) onTap?() diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/SessionUIKit/Components/Input View/MentionSelectionView.swift similarity index 66% rename from Session/Conversations/Input View/MentionSelectionView.swift rename to SessionUIKit/Components/Input View/MentionSelectionView.swift index 5abdeebb4b..0248ad8d2d 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/SessionUIKit/Components/Input View/MentionSelectionView.swift @@ -1,24 +1,34 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit -import SignalUtilitiesKit - -final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate { - private let dependencies: Dependencies - var currentUserSessionIds: Set = [] - var candidates: [MentionInfo] = [] { + +public final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate { + public static let profilePictureViewSize: ProfilePictureView.Info.Size = .message + + public struct ViewModel { + public static let mentionChar: String = "@" // stringlint:ignore + + public let profileId: String + public let displayName: String + public let profilePictureInfo: ProfilePictureView.Info + + public init(profileId: String, displayName: String, profilePictureInfo: ProfilePictureView.Info) { + self.profileId = profileId + self.displayName = displayName + self.profilePictureInfo = profilePictureInfo + } + } + + private let dataManager: ImageDataManagerType + public weak var delegate: MentionSelectionViewDelegate? + public var candidates: [ViewModel] = [] { didSet { tableView.isScrollEnabled = (candidates.count > 4) tableView.reloadData() } } - weak var delegate: MentionSelectionViewDelegate? - - var contentOffset: CGPoint { + public var contentOffset: CGPoint { get { tableView.contentOffset } set { tableView.contentOffset = newValue } } @@ -39,15 +49,15 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele // MARK: - Initialization - init(using dependencies: Dependencies) { - self.dependencies = dependencies + public init(dataManager: ImageDataManagerType) { + self.dataManager = dataManager super.init(frame: .zero) setUpViewHierarchy() } - @available(*, unavailable, message: "use other init(using:) instead.") + @available(*, unavailable, message: "use other init(dataManager:) instead.") required public init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -79,29 +89,19 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele // MARK: - Data - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return candidates.count } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath) cell.update( - with: candidates[indexPath.row].profile, - threadVariant: candidates[indexPath.row].threadVariant, - isUserModeratorOrAdmin: dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: candidates[indexPath.row].profile.id, - for: candidates[indexPath.row].openGroupRoomToken, - on: candidates[indexPath.row].openGroupServer, - currentUserSessionIds: currentUserSessionIds - ), - currentUserSessionIds: currentUserSessionIds, + with: candidates[indexPath.row], isLast: (indexPath.row == (candidates.count - 1)), - using: dependencies + dataManager: dataManager ) cell.accessibilityIdentifier = "Contact" - cell.accessibilityLabel = candidates[indexPath.row].profile.displayName( - for: candidates[indexPath.row].threadVariant - ) + cell.accessibilityLabel = candidates[indexPath.row].displayName cell.isAccessibilityElement = true return cell @@ -109,7 +109,7 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele // MARK: - Interaction - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let mentionCandidate = candidates[indexPath.row] delegate?.handleMentionSelected(mentionCandidate, from: self) @@ -123,7 +123,7 @@ private extension MentionSelectionView { // MARK: - UI private lazy var profilePictureView: ProfilePictureView = ProfilePictureView( - size: .message, + size: profilePictureViewSize, dataManager: nil ) @@ -172,7 +172,7 @@ private extension MentionSelectionView { mainStackView.axis = .horizontal mainStackView.alignment = .center mainStackView.spacing = Values.mediumSpacing - mainStackView.set(.height, to: ProfilePictureView.Size.message.viewSize) + mainStackView.set(.height, to: ProfilePictureView.Info.Size.message.viewSize) contentView.addSubview(mainStackView) mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing) @@ -190,26 +190,13 @@ private extension MentionSelectionView { // MARK: - Updating fileprivate func update( - with profile: Profile, - threadVariant: SessionThread.Variant, - isUserModeratorOrAdmin: Bool, - currentUserSessionIds: Set, + with viewModel: MentionSelectionView.ViewModel, isLast: Bool, - using dependencies: Dependencies + dataManager: ImageDataManagerType ) { - displayNameLabel.text = profile.displayNameForMention( - for: threadVariant, - currentUserSessionIds: currentUserSessionIds - ) - profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) - profilePictureView.update( - publicKey: profile.id, - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: profile, - profileIcon: (isUserModeratorOrAdmin ? .crown : .none), - using: dependencies - ) + displayNameLabel.text = viewModel.displayName + profilePictureView.setDataManager(dataManager) + profilePictureView.update(viewModel.profilePictureInfo) separator.isHidden = isLast } } @@ -217,6 +204,25 @@ private extension MentionSelectionView { // MARK: - Delegate -protocol MentionSelectionViewDelegate: AnyObject { - @MainActor func handleMentionSelected(_ mention: MentionInfo, from view: MentionSelectionView) +public protocol MentionSelectionViewDelegate: AnyObject { + @MainActor func handleMentionSelected(_ viewModel: MentionSelectionView.ViewModel, from view: MentionSelectionView) +} + +// MARK: - Convenience + +public extension Collection where Element == MentionSelectionView.ViewModel { + func update(_ string: String) -> String { + let mentionChar: String = MentionSelectionView.ViewModel.mentionChar + var result: String = string + + for mention in self { + guard let range: Range = result.range(of: "\(mentionChar)\(mention.displayName)") else { + continue + } + + result = result.replacingCharacters(in: range, with: "\(mentionChar)\(mention.profileId)") + } + + return result + } } diff --git a/Session/Conversations/Input View/VoiceMessageRecordingView.swift b/SessionUIKit/Components/Input View/VoiceMessageRecordingView.swift similarity index 94% rename from Session/Conversations/Input View/VoiceMessageRecordingView.swift rename to SessionUIKit/Components/Input View/VoiceMessageRecordingView.swift index de2c7be67c..bbc0c2ff61 100644 --- a/Session/Conversations/Input View/VoiceMessageRecordingView.swift +++ b/SessionUIKit/Components/Input View/VoiceMessageRecordingView.swift @@ -1,10 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import SessionUIKit -import SessionUtilitiesKit -final class VoiceMessageRecordingView: UIView { +public final class VoiceMessageRecordingView: UIView { private let voiceMessageButtonFrame: CGRect private weak var delegate: VoiceMessageRecordingViewDelegate? private lazy var slideToCancelStackViewTrailingConstraint = slideToCancelStackView.pin(.trailing, to: .trailing, of: self) @@ -61,7 +59,7 @@ final class VoiceMessageRecordingView: UIView { private lazy var chevronImageView: UIImageView = { let result: UIImageView = UIImageView( - image: (Dependencies.isRTL ? + image: (SNUIKit.isRTL ? UIImage(named: "small_chevron_left")?.withHorizontallyFlippedOrientation() : UIImage(named: "small_chevron_left") )? @@ -140,7 +138,7 @@ final class VoiceMessageRecordingView: UIView { // MARK: - Lifecycle - init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate?) { + public init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate?) { self.voiceMessageButtonFrame = voiceMessageButtonFrame self.delegate = delegate @@ -171,9 +169,8 @@ final class VoiceMessageRecordingView: UIView { // Note: We intentionally pin to '.left' here as the frame calculations don't take // LRT/RTL language direction into account - 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))) + iconImageView.pin(.left, to: .left, of: self, withInset: (voiceMessageButtonFrame.midX - (iconSize / 2))) + iconImageView.pin(.top, to: .top, of: self, withInset: (voiceMessageButtonFrame.midY - (iconSize / 2))) // Circle insertSubview(circleView, at: 0) @@ -218,7 +215,7 @@ final class VoiceMessageRecordingView: UIView { // MARK: - Animation - func animate() { + public func animate() { layoutIfNeeded() slideToCancelStackViewTrailingConstraint.isActive = false @@ -275,10 +272,10 @@ final class VoiceMessageRecordingView: UIView { // MARK: - Interaction - func handleLongPressMoved(to location: CGPoint) { - if ((!Dependencies.isRTL && location.x < bounds.center.x) || (Dependencies.isRTL && location.x > bounds.center.x)) { - let translationX = location.x - bounds.center.x - let sign: CGFloat = (Dependencies.isRTL ? 1 : -1) + public func handleLongPressMoved(to location: CGPoint) { + if ((!SNUIKit.isRTL && location.x < bounds.midX) || (SNUIKit.isRTL && location.x > bounds.midX)) { + let translationX = location.x - bounds.midX + let sign: CGFloat = (SNUIKit.isRTL ? 1 : -1) let chevronDamping: CGFloat = 4 let labelDamping: CGFloat = 3 let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign @@ -310,7 +307,7 @@ final class VoiceMessageRecordingView: UIView { } } - func handleLongPressEnded(at location: CGPoint) { + public func handleLongPressEnded(at location: CGPoint) { if pulseView.frame.contains(location) { delegate?.endVoiceMessageRecording() } @@ -470,8 +467,8 @@ extension VoiceMessageRecordingView { // MARK: - Delegate -protocol VoiceMessageRecordingViewDelegate: AnyObject { - func startVoiceMessageRecording() - func endVoiceMessageRecording() - func cancelVoiceMessageRecording() +public protocol VoiceMessageRecordingViewDelegate: AnyObject { + @MainActor func startVoiceMessageRecording() + @MainActor func endVoiceMessageRecording() + @MainActor func cancelVoiceMessageRecording() } diff --git a/SessionUIKit/Components/LinkPreviewView.swift b/SessionUIKit/Components/LinkPreviewView.swift new file mode 100644 index 0000000000..57bf0f56d7 --- /dev/null +++ b/SessionUIKit/Components/LinkPreviewView.swift @@ -0,0 +1,233 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import NVActivityIndicatorView + +// MARK: - LinkPreviewViewModel + +public struct LinkPreviewViewModel { + public enum State { + case loading + case draft + case sent + } + + public var state: State + public var urlString: String + public var title: String? + public var imageSource: ImageDataManager.DataSource? + + public init( + state: State, + urlString: String, + title: String? = nil, + imageSource: ImageDataManager.DataSource? = nil + ) { + self.state = state + self.urlString = urlString + self.title = title + self.imageSource = imageSource + } +} + +// MARK: - LinkPreviewView + +public final class LinkPreviewView: UIView { + private static let loaderSize: CGFloat = 24 + private static let cancelButtonSize: CGFloat = 45 + + private let onCancel: (() -> ())? + + // MARK: - UI + + private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100) + private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100) + + // MARK: UI Components + + public var previewView: UIView { hStackView } + + private lazy var imageView: SessionImageView = { + let result: SessionImageView = SessionImageView() + result.contentMode = .scaleAspectFill + + return result + }() + + private lazy var imageViewContainer: UIView = { + let result: UIView = UIView() + result.clipsToBounds = true + + return result + }() + + private let loader: NVActivityIndicatorView = { + let result: NVActivityIndicatorView = NVActivityIndicatorView( + frame: CGRect.zero, + type: .circleStrokeSpin, + color: .black, + padding: nil + ) + + ThemeManager.onThemeChange(observer: result) { [weak result] _, _, resolve in + guard let textPrimary: UIColor = resolve(.textPrimary) else { return } + + result?.color = textPrimary + } + + return result + }() + + private lazy var titleLabelContainer: UIView = { + let result: UIView = UIView() + result.addSubview(titleLabel) + titleLabel.pin(to: result, withInset: Values.mediumSpacing) + + return result + }() + + private lazy var titleLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.numberOfLines = 0 + + return result + }() + + private lazy var hStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + imageViewContainer, + titleLabelContainer, + cancelButton + ]) + result.axis = .horizontal + result.alignment = .center + + return result + }() + + private lazy var cancelButton: UIButton = { + let result: UIButton = UIButton(type: .custom) + result.setImage( + UIImage(named: "X")? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) + result.themeTintColor = .textPrimary + result.set(.width, to: LinkPreviewView.cancelButtonSize) + result.set(.height, to: LinkPreviewView.cancelButtonSize) + result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) + + return result + }() + + var bodyTappableLabel: TappableLabel? + + // MARK: - Initialization + + public init(onCancel: (() -> ())? = nil) { + self.onCancel = onCancel + + super.init(frame: CGRect.zero) + + setUpViewHierarchy() + } + + override init(frame: CGRect) { + self.onCancel = nil + + super.init(frame: CGRect.zero) + + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(for:onCancel:) instead.") + } + + private func setUpViewHierarchy() { + // Image view + imageViewContainerWidthConstraint.isActive = true + imageViewContainerHeightConstraint.isActive = true + imageViewContainer.addSubview(imageView) + imageView.pin(to: imageViewContainer) + + // Horizontal stack view + addSubview(hStackView) + hStackView.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 + + @MainActor public func update( + with viewModel: LinkPreviewViewModel, + isOutgoing: Bool, + dataManager: ImageDataManagerType + ) { + // Image view + let imageViewContainerSize: CGFloat = (viewModel.state == .sent ? 100 : 80) + imageViewContainerWidthConstraint.constant = imageViewContainerSize + imageViewContainerHeightConstraint.constant = imageViewContainerSize + imageViewContainer.layer.cornerRadius = (viewModel.state == .sent ? 0 : 8) + imageView.themeTintColor = (isOutgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + + // Title + titleLabel.text = viewModel.title + titleLabel.themeTextColor = (isOutgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + + let imageContentExists: Bool = (viewModel.imageSource?.contentExists == true) + let imageSource: ImageDataManager.DataSource = { + guard + let source: ImageDataManager.DataSource = viewModel.imageSource, + source.contentExists + else { return .icon(.link, size: 32, renderingMode: .alwaysTemplate) } + + return source + }() + + loader.alpha = (viewModel.state == .loading ? 1 : 0) + imageView.setDataManager(dataManager) + imageView.contentMode = (imageContentExists ? .scaleAspectFill : .center) + cancelButton.isHidden = (viewModel.state != .draft) + + switch viewModel.state { + case .loading: + loader.startAnimating() + imageView.image = nil + themeBackgroundColor = nil + imageViewContainer.themeBackgroundColor = .clear + + case .sent: + loader.stopAnimating() + imageView.loadImage(imageSource) + themeBackgroundColor = .messageBubble_overlay + imageViewContainer.themeBackgroundColor = .messageBubble_overlay + + case .draft: + loader.stopAnimating() + imageView.loadImage(imageSource) + themeBackgroundColor = nil + imageViewContainer.themeBackgroundColor = .messageBubble_overlay + } + } + + // MARK: - Interaction + + @objc private func cancel() { + onCancel?() + } +} diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 66663822e7..f444f77951 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -797,7 +797,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { public extension ConfirmationModal { enum ValueUpdate { case input(String) - case image(source: ImageDataManager.DataSource, cropRect: CGRect?, replacementIcon: ProfilePictureView.ProfileIcon?, replacementCancelTitle: String?) + case image(source: ImageDataManager.DataSource, cropRect: CGRect?, replacementIcon: ProfilePictureView.Info.ProfileIcon?, replacementCancelTitle: String?) } struct Info: Equatable, Hashable { @@ -1078,7 +1078,7 @@ public extension ConfirmationModal.Info { case image( source: ImageDataManager.DataSource?, placeholder: ImageDataManager.DataSource?, - icon: ProfilePictureView.ProfileIcon = .none, + icon: ProfilePictureView.Info.ProfileIcon = .none, style: ImageStyle, description: NSAttributedString?, accessibility: Accessibility?, diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index d8ef43f246..950755286c 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -12,6 +12,76 @@ public final class ProfilePictureView: UIView { case currentUser(SessionProManagerType) } + public enum Size { + case navigation + case message + case list + case hero + case modal + + public var viewSize: CGFloat { + switch self { + case .navigation, .message: return 26 + case .list: return 46 + case .hero: return 110 + case .modal: return 90 + } + } + + public var imageSize: CGFloat { + switch self { + case .navigation, .message: return 26 + case .list: return 46 + case .hero: return 80 + case .modal: return 90 + } + } + + public var multiImageSize: CGFloat { + switch self { + case .navigation, .message: return 18 // Shouldn't be used + case .list: return 32 + case .hero: return 80 + case .modal: return 90 + } + } + + var iconSize: CGFloat { + switch self { + case .navigation, .message: return 10 // Intentionally not a multiple of 4 + case .list: return 16 + case .hero: return 24 + case .modal: return 24 // Shouldn't be used + } + } + } + + public enum ProfileIcon: Equatable, Hashable { + case none + case crown + case rightPlus + case letter(Character, Bool) + case pencil + + func iconVerticalInset(for size: Size) -> CGFloat { + switch (self, size) { + case (.crown, .navigation), (.crown, .message): return 1 + case (.crown, .list): return 3 + case (.crown, .hero): return 5 + + case (.rightPlus, _): return 3 + default: return 0 + } + } + + var isLeadingAligned: Bool { + switch self { + case .none, .crown, .letter: return true + case .rightPlus, .pencil: return false + } + } + } + let source: ImageDataManager.DataSource? let animationBehaviour: AnimationBehaviour let renderingMode: UIImage.RenderingMode? @@ -45,79 +115,9 @@ public final class ProfilePictureView: UIView { } } - public enum Size { - case navigation - case message - case list - case hero - case modal - - public var viewSize: CGFloat { - switch self { - case .navigation, .message: return 26 - case .list: return 46 - case .hero: return 110 - case .modal: return 90 - } - } - - public var imageSize: CGFloat { - switch self { - case .navigation, .message: return 26 - case .list: return 46 - case .hero: return 80 - case .modal: return 90 - } - } - - public var multiImageSize: CGFloat { - switch self { - case .navigation, .message: return 18 // Shouldn't be used - case .list: return 32 - case .hero: return 80 - case .modal: return 90 - } - } - - var iconSize: CGFloat { - switch self { - case .navigation, .message: return 10 // Intentionally not a multiple of 4 - case .list: return 16 - case .hero: return 24 - case .modal: return 24 // Shouldn't be used - } - } - } - - public enum ProfileIcon: Equatable, Hashable { - case none - case crown - case rightPlus - case letter(Character, Bool) - case pencil - - func iconVerticalInset(for size: Size) -> CGFloat { - switch (self, size) { - case (.crown, .navigation), (.crown, .message): return 1 - case (.crown, .list): return 3 - case (.crown, .hero): return 5 - - case (.rightPlus, _): return 3 - default: return 0 - } - } - - var isLeadingAligned: Bool { - switch self { - case .none, .crown, .letter: return true - case .rightPlus, .pencil: return false - } - } - } - private var dataManager: ImageDataManagerType? private var disposables: Set = Set() - public var size: Size { + public var size: Info.Size { didSet { widthConstraint.constant = (customWidth ?? size.viewSize) heightConstraint.constant = size.viewSize @@ -282,7 +282,7 @@ public final class ProfilePictureView: UIView { // MARK: - Lifecycle - public init(size: Size, dataManager: ImageDataManagerType?) { + public init(size: Info.Size, dataManager: ImageDataManagerType?) { self.dataManager = dataManager self.size = size @@ -389,7 +389,7 @@ public final class ProfilePictureView: UIView { // MARK: - Content private func updateIconView( - icon: ProfileIcon, + icon: Info.ProfileIcon, imageView: UIImageView, label: UILabel, backgroundView: UIView, @@ -655,13 +655,13 @@ import SwiftUI public struct ProfilePictureSwiftUI: UIViewRepresentable { public typealias UIViewType = ProfilePictureView - var size: ProfilePictureView.Size + var size: ProfilePictureView.Info.Size var info: ProfilePictureView.Info var additionalInfo: ProfilePictureView.Info? let dataManager: ImageDataManagerType public init( - size: ProfilePictureView.Size, + size: ProfilePictureView.Info.Size, info: ProfilePictureView.Info, additionalInfo: ProfilePictureView.Info? = nil, dataManager: ImageDataManagerType diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/SessionUIKit/Components/QuoteView.swift similarity index 50% rename from Session/Conversations/Message Cells/Content Views/QuoteView.swift rename to SessionUIKit/Components/QuoteView.swift index be5ab7daa5..134deb40bc 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/SessionUIKit/Components/QuoteView.swift @@ -1,11 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit +import UniformTypeIdentifiers -final class QuoteView: UIView { +public final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 static let iconSize: CGFloat = 24 static let labelStackViewSpacing: CGFloat = 2 @@ -20,36 +18,24 @@ final class QuoteView: UIView { // MARK: - Variables - private let dependencies: Dependencies - private let onCancel: (() -> ())? + private let viewModel: QuoteViewModel + private let dataManager: ImageDataManagerType + private var onCancel: (() -> Void)? // MARK: - Lifecycle - init( - for mode: Mode, - authorId: String, - quotedText: String?, - threadVariant: SessionThread.Variant, - currentUserSessionIds: Set, - direction: Direction, - attachment: Attachment?, - using dependencies: Dependencies, - onCancel: (() -> ())? = nil + public init( + viewModel: QuoteViewModel, + dataManager: ImageDataManagerType, + onCancel: (() -> Void)? = nil ) { - self.dependencies = dependencies + self.viewModel = viewModel + self.dataManager = dataManager self.onCancel = onCancel super.init(frame: CGRect.zero) - setUpViewHierarchy( - mode: mode, - authorId: authorId, - quotedText: quotedText, - threadVariant: threadVariant, - currentUserSessionIds: currentUserSessionIds, - direction: direction, - attachment: attachment - ) + setUpViewHierarchy(viewModel: viewModel) } override init(frame: CGRect) { @@ -60,15 +46,7 @@ final class QuoteView: UIView { preconditionFailure("Use init(for:maxMessageWidth:) instead.") } - private func setUpViewHierarchy( - mode: Mode, - authorId: String, - quotedText: String?, - threadVariant: SessionThread.Variant, - currentUserSessionIds: Set, - direction: Direction, - attachment: Attachment? - ) { + private func setUpViewHierarchy(viewModel: QuoteViewModel) { // There's quite a bit of calculation going on here. It's a bit complex so don't make changes // if you don't need to. If you do then test: // • Quoted text in both private chats and group chats @@ -81,14 +59,13 @@ final class QuoteView: UIView { let labelStackViewVMargin = QuoteView.labelStackViewVMargin let smallSpacing = Values.smallSpacing let cancelButtonSize = QuoteView.cancelButtonSize - var body: String? = quotedText // 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.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: smallSpacing) mainStackView.alignment = .center mainStackView.setCompressionResistance(.vertical, to: .required) @@ -97,57 +74,39 @@ final class QuoteView: UIView { addSubview(contentView) contentView.pin(to: self) - if let attachment: Attachment = attachment { - let isAudio: Bool = attachment.isAudio - let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black") // stringlint:ignore + if viewModel.hasAttachment { let imageContainerView: UIView = UIView() imageContainerView.themeBackgroundColor = .messageBubble_overlay - imageContainerView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius + imageContainerView.layer.cornerRadius = 4 imageContainerView.layer.masksToBounds = true imageContainerView.set(.width, to: thumbnailSize) imageContainerView.set(.height, to: thumbnailSize) mainStackView.addArrangedSubview(imageContainerView) let imageView: SessionImageView = SessionImageView( - image: UIImage(named: fallbackImageName)?.withRenderingMode(.alwaysTemplate), - dataManager: dependencies[singleton: .imageDataManager] + image: viewModel.fallbackImage, + dataManager: dataManager ) - imageView.themeTintColor = { - switch mode { - case .regular: return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - case .draft: return .textPrimary - } - }() + imageView.themeTintColor = viewModel.targetThemeColor imageView.contentMode = .scaleAspectFit imageView.set(.width, to: iconSize) imageView.set(.height, to: iconSize) imageContainerView.addSubview(imageView) imageView.center(in: imageContainerView) - if (body ?? "").isEmpty { - body = attachment.shortDescription - } - // Generate the thumbnail if needed - imageView.loadThumbnail(size: .small, attachment: attachment, using: dependencies) { [weak imageView] buffer in - guard buffer != nil else { return } - - imageView?.contentMode = .scaleAspectFill + if let source: ImageDataManager.DataSource = viewModel.quotedAttachmentInfo?.thumbnailSource { + imageView.loadImage(source) { [weak imageView] buffer in + guard buffer != nil else { return } + + imageView?.contentMode = .scaleAspectFill + } } } else { // Line view - let lineColor: ThemeValue = { - switch mode { - case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary) - case .draft: return .primary - } - }() let lineView = UIView() - lineView.themeBackgroundColor = lineColor + lineView.themeBackgroundColor = viewModel.lineColor mainStackView.addArrangedSubview(lineView) lineView.pin(.top, to: .top, of: mainStackView) @@ -159,65 +118,13 @@ final class QuoteView: UIView { let bodyLabel = TappableLabel() bodyLabel.lineBreakMode = .byTruncatingTail bodyLabel.numberOfLines = 2 - - let targetThemeColor: ThemeValue = { - switch mode { - case .regular: return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - case .draft: return .textPrimary - } - }() - bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) - bodyLabel.themeAttributedText = body - .map { - MentionUtilities.highlightMentions( - in: $0, - threadVariant: threadVariant, - currentUserSessionIds: currentUserSessionIds, - location: { - switch (mode, direction) { - case (.draft, _): return .quoteDraft - case (_, .outgoing): return .outgoingQuote - case (_, .incoming): return .incomingQuote - } - }(), - textColor: targetThemeColor, - attributes: [ - .themeForegroundColor: targetThemeColor - ], - using: dependencies - ) - } - .defaulting( - to: attachment.map { - ThemedAttributedString(string: $0.shortDescription, attributes: [ .themeForegroundColor: targetThemeColor ]) - } - ) - .defaulting(to: ThemedAttributedString(string: "messageErrorOriginal".localized(), attributes: [ .themeForegroundColor: targetThemeColor ])) + bodyLabel.themeAttributedText = viewModel.attributedText // Label stack view let authorLabel = UILabel() authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = { - guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } - guard body != nil else { - // When we can't find the quoted message we want to hide the author label - return Profile.displayNameNoFallback( - id: authorId, - threadVariant: threadVariant, - using: dependencies - ) - } - - return Profile.displayName( - id: authorId, - threadVariant: threadVariant, - using: dependencies - ) - }() - authorLabel.themeTextColor = targetThemeColor + authorLabel.text = viewModel.author + authorLabel.themeTextColor = viewModel.targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.isHidden = (authorLabel.text == nil) authorLabel.numberOfLines = 1 @@ -236,7 +143,7 @@ final class QuoteView: UIView { contentView.addSubview(mainStackView) mainStackView.pin(to: contentView) - if mode == .draft { + if viewModel.mode == .draft { // Cancel button let cancelButton = UIButton(type: .custom) cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: .normal) @@ -255,4 +162,12 @@ final class QuoteView: UIView { @objc private func cancel() { onCancel?() } + + // MARK: - Functions + + public func update(viewModel: QuoteViewModel) { + subviews.forEach { $0.removeFromSuperview() } + + setUpViewHierarchy(viewModel: viewModel) + } } diff --git a/SessionUIKit/Components/SessionImageView.swift b/SessionUIKit/Components/SessionImageView.swift index 633ddb013d..6c6cbb600e 100644 --- a/SessionUIKit/Components/SessionImageView.swift +++ b/SessionUIKit/Components/SessionImageView.swift @@ -212,39 +212,7 @@ public class SessionImageView: UIImageView { } @MainActor - public func setAnimationPoint(index: Int, time: TimeInterval) { - guard index >= 0, index < frameBuffer?.frameCount ?? 0 else { return } - // TODO: Won't this break the animation???? - Task { -// currentFrameIndex = index -// self.image = await frameBuffer?.getFrame(at: index) -// frameBuffer?. - /// Stop animating if we don't have a valid animation state - guard - let durations = frameBuffer?.durations, - index >= 0, - index < durations.count, - time > 0, - time < durations.reduce(0, +) - else { - image = frameBuffer?.getFrame(at: index) - currentFrameIndex = 0 - accumulatedTime = 0 - return stopAnimationLoop() - } - - /// Update the values - accumulatedTime = time - currentFrameIndex = index - - /// Set the image using `super.image` as `self.image` is overwritten to stop the animation (in case it gets called - /// to replace the current image with something else) - super.image = frameBuffer?.getFrame(at: index) - } - } - - @MainActor - public func copyAnimationPoint(from other: SessionImageView) { + public func copyContentAndAnimationPoint(from other: SessionImageView) { self.handleLoadedImageData(other.frameBuffer) self.image = other.image self.currentFrameIndex = other.currentFrameIndex diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 6b4ed4802c..3f09709c3d 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -370,7 +370,6 @@ public protocol SessionProManagerType: AnyObject { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool @@ -380,14 +379,12 @@ public protocol SessionProManagerType: AnyObject { public extension SessionProManagerType { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { showSessionProCTAIfNeeded( variant, dismissType: .recursive, - beforePresented: beforePresented, afterClosed: afterClosed, presenting: presenting ) @@ -400,7 +397,6 @@ public extension SessionProManagerType { showSessionProCTAIfNeeded( variant, dismissType: .recursive, - beforePresented: nil, afterClosed: nil, presenting: presenting ) diff --git a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift new file mode 100644 index 0000000000..c9e9002208 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift @@ -0,0 +1,438 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import UniformTypeIdentifiers + +public struct QuoteViewModel: Equatable, Hashable { + public enum Mode: Equatable, Hashable { case regular, draft } + public enum Direction: Equatable, Hashable { case incoming, outgoing } + public struct AttachmentInfo: Equatable, Hashable { + public let id: String + public let utType: UTType + public let isVoiceMessage: Bool + public let downloadUrl: String? + public let sourceFilename: String? + public let thumbnailSource: ImageDataManager.DataSource? + + public init( + id: String, + utType: UTType, + isVoiceMessage: Bool, + downloadUrl: String?, + sourceFilename: String?, + thumbnailSource: ImageDataManager.DataSource? + ) { + self.id = id + self.utType = utType + self.isVoiceMessage = isVoiceMessage + self.downloadUrl = downloadUrl + self.sourceFilename = sourceFilename + self.thumbnailSource = thumbnailSource + } + } + + public let mode: Mode + public let direction: Direction + public let currentUserSessionIds: Set + public let rowId: Int64 + public let interactionId: Int64? + public let authorId: String + public let timestampMs: Int64 + public let quotedInteractionId: Int64 + public let quotedInteractionIsDeleted: Bool + public let quotedText: String? + public let quotedAttachmentInfo: AttachmentInfo? + let displayNameRetriever: (String, Bool) -> String? + + // MARK: - Computed Properties + + var hasAttachment: Bool { quotedAttachmentInfo != nil } + var author: String? { + guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } + guard quotedText != nil else { + // When we can't find the quoted message we want to hide the author label + return displayNameRetriever(authorId, false) + } + + return (displayNameRetriever(authorId, false) ?? authorId.truncated()) + } + + var fallbackImage: UIImage? { + guard let utType: UTType = quotedAttachmentInfo?.utType else { return nil } + + let fallbackImageName: String = (utType.conforms(to: .audio) ? "attachment_audio" : "actionsheet_document_black") + + guard let image = UIImage(named: fallbackImageName)?.withRenderingMode(.alwaysTemplate) else { + return nil + } + + return image + } + + var targetThemeColor: ThemeValue { + switch mode { + case .draft: return .textPrimary + case .regular: + return (direction == .outgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + } + } + + var lineColor: ThemeValue { + switch mode { + case .draft: return .primary + case .regular: + return (direction == .outgoing ? + .messageBubble_outgoingText : + .primary + ) + + } + } + + var mentionLocation: MentionUtilities.MentionLocation { + switch (mode, direction) { + case (.draft, _): return .quoteDraft + case (_, .outgoing): return .outgoingQuote + case (_, .incoming): return .incomingQuote + } + } + + var attributedText: ThemedAttributedString? { + let text: String = { + switch (quotedText, quotedAttachmentInfo) { + case (.some(let text), _) where !text.isEmpty: return text + case (_, .some(let info)): + return info.utType.shortDescription(isVoiceMessage: info.isVoiceMessage) + + case (.some, .none), (.none, .none): return "messageErrorOriginal".localized() + } + }() + + return MentionUtilities.highlightMentions( + in: text, + currentUserSessionIds: currentUserSessionIds, + location: mentionLocation, + textColor: targetThemeColor, + attributes: [ + .themeForegroundColor: targetThemeColor, + .font: UIFont.systemFont(ofSize: Values.smallFontSize) + ], + displayNameRetriever: displayNameRetriever + ) + } + + // MARK: - Initialization + + public init( + mode: Mode, + direction: Direction, + currentUserSessionIds: Set, + rowId: Int64, + interactionId: Int64?, + authorId: String, + timestampMs: Int64, + quotedInteractionId: Int64, + quotedInteractionIsDeleted: Bool, + quotedText: String?, + quotedAttachmentInfo: AttachmentInfo?, + displayNameRetriever: @escaping (String, Bool) -> String? + ) { + self.mode = mode + self.direction = direction + self.currentUserSessionIds = currentUserSessionIds + self.rowId = rowId + self.interactionId = interactionId + self.authorId = authorId + self.timestampMs = timestampMs + self.quotedInteractionId = quotedInteractionId + self.quotedInteractionIsDeleted = quotedInteractionIsDeleted + self.quotedText = quotedText + self.quotedAttachmentInfo = quotedAttachmentInfo + self.displayNameRetriever = displayNameRetriever + } + + public init(previewBody: String) { + self.quotedText = previewBody + + /// This is an preview version so none of these values matter + self.mode = .regular + self.direction = .incoming + self.currentUserSessionIds = [] + self.rowId = -1 + self.interactionId = nil + self.authorId = "" + self.timestampMs = 0 + self.quotedInteractionId = 0 + self.quotedInteractionIsDeleted = false + self.quotedAttachmentInfo = nil + self.displayNameRetriever = { _, _ in nil } + } + + // MARK: - Conformance + + public static func == (lhs: QuoteViewModel, rhs: QuoteViewModel) -> Bool { + return ( + lhs.mode == rhs.mode && + lhs.direction == rhs.direction && + lhs.currentUserSessionIds == rhs.currentUserSessionIds && + lhs.rowId == rhs.rowId && + lhs.interactionId == rhs.interactionId && + lhs.authorId == rhs.authorId && + lhs.timestampMs == rhs.timestampMs && + lhs.quotedInteractionId == rhs.quotedInteractionId && + lhs.quotedInteractionIsDeleted == rhs.quotedInteractionIsDeleted && + lhs.quotedText == rhs.quotedText && + lhs.quotedAttachmentInfo == rhs.quotedAttachmentInfo + ) + } + + public func hash(into hasher: inout Hasher) { + mode.hash(into: &hasher) + direction.hash(into: &hasher) + currentUserSessionIds.hash(into: &hasher) + rowId.hash(into: &hasher) + interactionId?.hash(into: &hasher) + authorId.hash(into: &hasher) + timestampMs.hash(into: &hasher) + quotedInteractionId.hash(into: &hasher) + quotedInteractionIsDeleted.hash(into: &hasher) + quotedText.hash(into: &hasher) + quotedAttachmentInfo.hash(into: &hasher) + } +} + +// MARK: - QuoteView + +public struct QuoteView_SwiftUI: View { + private static let thumbnailSize: CGFloat = 48 + private static let iconSize: CGFloat = 24 + private static let labelStackViewSpacing: CGFloat = 2 + private static let labelStackViewVMargin: CGFloat = 4 + private static let cancelButtonSize: CGFloat = 33 + private static let cornerRadius: CGFloat = 4 + + private var viewModel: QuoteViewModel + private var dataManager: ImageDataManagerType + private var onCancel: (() -> Void)? + + public init( + viewModel: QuoteViewModel, + dataManager: ImageDataManagerType, + onCancel: (() -> Void)? = nil + ) { + self.viewModel = viewModel + self.dataManager = dataManager + self.onCancel = onCancel + } + + public var body: some View { + HStack( + alignment: .center, + spacing: Values.smallSpacing + ) { + if viewModel.hasAttachment { + ZStack() { + RoundedRectangle( + cornerRadius: Self.cornerRadius + ) + .fill(themeColor: .messageBubble_overlay) + .frame( + width: Self.thumbnailSize, + height: Self.thumbnailSize + ) + + if let source: ImageDataManager.DataSource = viewModel.quotedAttachmentInfo?.thumbnailSource { + SessionAsyncImage( + source: source, + dataManager: dataManager + ) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + if let image: UIImage = viewModel.fallbackImage { + Image(uiImage: image) + .foregroundColor(themeColor: viewModel.targetThemeColor) + } + + Color.clear + } + .frame( + width: Self.iconSize, + height: Self.iconSize, + alignment: .center + ) + } + else { + if let image: UIImage = viewModel.fallbackImage { + Image(uiImage: image) + .foregroundColor(themeColor: viewModel.targetThemeColor) + .frame( + width: Self.iconSize, + height: Self.iconSize, + alignment: .center + ) + } + + Color.clear + .frame( + width: Self.iconSize, + height: Self.iconSize, + alignment: .center + ) + } + } + } else { + // Line view + Rectangle() + .foregroundColor(themeColor: viewModel.lineColor) + .frame(width: Values.accentLineThickness) + } + + // Quoted text and author + VStack( + alignment: .leading, + spacing: Self.labelStackViewSpacing + ) { + if let author: String = viewModel.author { + Text(author) + .bold() + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: viewModel.targetThemeColor) + } + + if let attributedText: ThemedAttributedString = viewModel.attributedText { + AttributedText(attributedText) + .lineLimit(2) + } else { + Text("messageErrorOriginal".localized()) + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: viewModel.targetThemeColor) + } + } + .padding(.vertical, Self.labelStackViewVMargin) + + if viewModel.mode == .draft { + // Cancel button + Button( + action: { + onCancel?() + }, + label: { + if let image = UIImage(named: "X")?.withRenderingMode(.alwaysTemplate) { + Image(uiImage: image) + .foregroundColor(themeColor: .textPrimary) + .frame( + width: Self.cancelButtonSize, + height: Self.cancelButtonSize, + alignment: .center + ) + } + } + ) + } + } + .padding(.trailing, Values.smallSpacing) + } +} + +struct QuoteView_SwiftUI_Previews: PreviewProvider { + static var previews: some View { + ZStack { + ThemeColor(.backgroundPrimary).ignoresSafeArea() + + VStack(spacing: 20) { + ZStack { + ThemeColor(.messageBubble_incomingBackground).ignoresSafeArea() + QuoteView_SwiftUI( + viewModel: QuoteViewModel( + mode: .draft, + direction: .outgoing, + currentUserSessionIds: ["05123"], + rowId: 0, + interactionId: nil, + authorId: "05123", + timestampMs: 0, + quotedInteractionId: 0, + quotedInteractionIsDeleted: false, + quotedText: nil, + quotedAttachmentInfo: nil, + displayNameRetriever: { _, _ in nil } + ), + dataManager: ImageDataManager() + ) + .frame(height: 40) + } + .frame( + width: 300, + height: 80 + ) + .cornerRadius(10) + + ZStack { + ThemeColor(.messageBubble_incomingBackground).ignoresSafeArea() + QuoteView_SwiftUI( + viewModel: QuoteViewModel( + mode: .draft, + direction: .outgoing, + currentUserSessionIds: [], + rowId: 0, + interactionId: nil, + authorId: "05123", + timestampMs: 0, + quotedInteractionId: 0, + quotedInteractionIsDeleted: false, + quotedText: "This was a message", + quotedAttachmentInfo: nil, + displayNameRetriever: { _, _ in "Some User" } + ), + dataManager: ImageDataManager() + ) + .frame(height: 40) + } + .frame( + width: 300, + height: 80 + ) + .cornerRadius(10) + + ZStack { + ThemeColor(.messageBubble_incomingBackground).ignoresSafeArea() + QuoteView_SwiftUI( + viewModel: QuoteViewModel( + mode: .regular, + direction: .incoming, + currentUserSessionIds: [], + rowId: 0, + interactionId: nil, + authorId: "", + timestampMs: 0, + quotedInteractionId: 0, + quotedInteractionIsDeleted: false, + quotedText: nil, + quotedAttachmentInfo: QuoteViewModel.AttachmentInfo( + id: "", + utType: .wav, + isVoiceMessage: false, + downloadUrl: nil, + sourceFilename: nil, + thumbnailSource: nil + ), + displayNameRetriever: { _, _ in nil } + ), + dataManager: ImageDataManager() + ) + .previewLayout(.sizeThatFits) + } + .frame( + width: 300, + height: 80 + ) + .cornerRadius(10) + } + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index b54bd7a0df..df746cc05e 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -79,8 +79,8 @@ public struct UserProfileModal: View { } } .frame( - width: ProfilePictureView.Size.modal.viewSize * scale, - height: ProfilePictureView.Size.modal.viewSize * scale, + width: ProfilePictureView.Info.Size.modal.viewSize * scale, + height: ProfilePictureView.Info.Size.modal.viewSize * scale, alignment: .center ) diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index b6545140cb..9ebbcc4a77 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -10,6 +10,7 @@ public actor SNUIKit { public protocol ConfigType { var maxFileSize: UInt { get } var isStorageValid: Bool { get } + var isRTL: Bool { get } func themeChanged(_ theme: Theme, _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool) func navBarSessionIcon() -> NavBarSessionIcon @@ -28,6 +29,7 @@ public actor SNUIKit { @MainActor public static var mainWindow: UIWindow? = nil internal static var config: ConfigType? = nil + private static let configLock = NSLock() @MainActor public static func setMainWindow(_ mainWindow: UIWindow) { self.mainWindow = mainWindow @@ -40,7 +42,16 @@ public actor SNUIKit { primaryColor: themeSettings?.primaryColor, matchSystemNightModeSetting: themeSettings?.matchSystemNightModeSetting ) + configLock.lock() self.config = config + configLock.unlock() + } + + public static var isRTL: Bool { + configLock.lock() + defer { configLock.unlock() } + + return config?.isRTL == true } internal static func themeSettingsChanged( @@ -48,50 +59,74 @@ public actor SNUIKit { _ primaryColor: Theme.PrimaryColor, _ matchSystemNightModeSetting: Bool ) { + configLock.lock() + defer { configLock.unlock() } + config?.themeChanged(theme, primaryColor, matchSystemNightModeSetting) } @MainActor internal static func navBarSessionIcon() -> NavBarSessionIcon { - guard let config: ConfigType = self.config else { return NavBarSessionIcon() } + configLock.lock() + defer { configLock.unlock() } - return config.navBarSessionIcon() + return (config?.navBarSessionIcon() ?? navBarSessionIcon()) } internal static func topBannerChanged(to warning: TopBannerController.Warning?) { guard let warning: TopBannerController.Warning = warning else { + configLock.lock() + defer { configLock.unlock() } + config?.persistentTopBannerChanged(warningKey: nil) return } guard warning.shouldAppearOnResume else { return } + configLock.lock() + defer { configLock.unlock() } + config?.persistentTopBannerChanged(warningKey: warning.rawValue) } public static func shouldShowStringKeys() -> Bool { - guard let config: ConfigType = self.config else { return false } + configLock.lock() + defer { configLock.unlock() } - return config.shouldShowStringKeys() + return (config?.shouldShowStringKeys() == true) } internal static func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { - guard let config: ConfigType = self.config else { return nil } + configLock.lock() + defer { configLock.unlock() } - return config.assetInfo(for: path, utType: utType, sourceFilename: sourceFilename) + return config?.assetInfo(for: path, utType: utType, sourceFilename: sourceFilename) } internal static func mediaDecoderDefaultImageOptions() -> CFDictionary? { + configLock.lock() + defer { configLock.unlock() } + return config?.mediaDecoderDefaultImageOptions() } internal static func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary? { + configLock.lock() + defer { configLock.unlock() } + return config?.mediaDecoderDefaultThumbnailOptions(maxDimension: maxDimension) } internal static func mediaDecoderSource(for url: URL) -> CGImageSource? { + configLock.lock() + defer { configLock.unlock() } + return config?.mediaDecoderSource(for: url) } internal static func mediaDecoderSource(for data: Data) -> CGImageSource? { + configLock.lock() + defer { configLock.unlock() } + return config?.mediaDecoderSource(for: data) } } diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift b/SessionUIKit/Types/LinkPreviewDraft.swift similarity index 92% rename from SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift rename to SessionUIKit/Types/LinkPreviewDraft.swift index 7d28a3df98..5a5de0efd4 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift +++ b/SessionUIKit/Types/LinkPreviewDraft.swift @@ -1,8 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import SessionUIKit - + TODO: Remove this and just use `LinkPreviewViewModel` public struct LinkPreviewDraft: Equatable, Hashable { public var urlString: String public var title: String? diff --git a/SessionUtilitiesKit/General/ReusableView.swift b/SessionUIKit/Types/ReusableView.swift similarity index 100% rename from SessionUtilitiesKit/General/ReusableView.swift rename to SessionUIKit/Types/ReusableView.swift diff --git a/SessionUtilitiesKit/Utilities/TimeUnit.swift b/SessionUIKit/Types/TimeUnit.swift similarity index 100% rename from SessionUtilitiesKit/Utilities/TimeUnit.swift rename to SessionUIKit/Types/TimeUnit.swift diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 3181285787..5db97f94bf 100644 --- a/SessionUIKit/Utilities/String+Utilities.swift +++ b/SessionUIKit/Utilities/String+Utilities.swift @@ -41,3 +41,104 @@ public extension String { return result.joined(separator: "\n") } } + +// MARK: - Truncation + +public extension String { + /// A standardised mechanism for truncating a user id + /// + /// stringlint:ignore_contents + func truncated(prefix: Int = 4, suffix: Int = 4) -> String { + guard count > (prefix + suffix) else { return self } + + return "\(self.prefix(prefix))...\(self.suffix(suffix))" + } +} + +// MARK: - Formatting + +public extension String.StringInterpolation { + mutating func appendInterpolation(_ value: TimeUnit, unit: TimeUnit.Unit, resolution: Int = 2) { + appendLiteral("\(TimeUnit(value, unit: unit, resolution: resolution))") + } + + mutating func appendInterpolation(_ value: Int, format: String) { + let result: String = String(format: "%\(format)d", value) + appendLiteral(result) + } + + mutating func appendInterpolation(_ value: Double, format: String, omitZeroDecimal: Bool = false) { + guard !omitZeroDecimal || Int(exactly: value) == nil else { + appendLiteral("\(Int(exactly: value)!)") + return + } + + let result: String = String(format: "%\(format)f", value) + appendLiteral(result) + } +} + +public extension String { + static func formattedDuration(_ duration: TimeInterval, format: TimeInterval.DurationFormat = .short, minimumUnit: NSCalendar.Unit = .second) -> String { + let dateComponentsFormatter = DateComponentsFormatter() + var allowedUnits: NSCalendar.Unit = [.weekOfMonth, .day, .hour, .minute, .second] + switch minimumUnit { + case .minute: + allowedUnits.remove(.second) + default: + break + } + dateComponentsFormatter.allowedUnits = allowedUnits + var calendar = Calendar.current + + switch format { + case .videoDuration: + guard duration < 3600 else { fallthrough } + dateComponentsFormatter.allowedUnits = [.minute, .second] + dateComponentsFormatter.unitsStyle = .positional + dateComponentsFormatter.zeroFormattingBehavior = .pad + return dateComponentsFormatter.string(from: duration) ?? "" + + case .hoursMinutesSeconds: + if duration < 3600 { + dateComponentsFormatter.allowedUnits = [.minute, .second] + dateComponentsFormatter.zeroFormattingBehavior = .pad + } else { + dateComponentsFormatter.allowedUnits = [.hour, .minute, .second] + dateComponentsFormatter.zeroFormattingBehavior = .default + } + dateComponentsFormatter.unitsStyle = .positional + // This is a workaroud for 00:00 to be shown as 0:00 + var str: String = dateComponentsFormatter.string(from: duration) ?? "" + if str.hasPrefix("0") { + str.remove(at: str.startIndex) + } + return str + + case .short: // Single unit, no localization, short version e.g. 1w + dateComponentsFormatter.maximumUnitCount = 1 + dateComponentsFormatter.unitsStyle = .abbreviated + calendar.locale = Locale(identifier: "en-US") + dateComponentsFormatter.calendar = calendar + return dateComponentsFormatter.string(from: duration) ?? "" + + case .long: // Single unit, long version e.g. 1 week + dateComponentsFormatter.maximumUnitCount = 1 + dateComponentsFormatter.unitsStyle = .full + return dateComponentsFormatter.string(from: duration) ?? "" + + case .twoUnits: // 2 units, no localization, short version e.g 1w 1d, remove trailing 0's e.g 12h 0m -> 12h + dateComponentsFormatter.maximumUnitCount = 2 + dateComponentsFormatter.unitsStyle = .abbreviated + dateComponentsFormatter.zeroFormattingBehavior = .dropAll + calendar.locale = Locale(identifier: "en-US") + dateComponentsFormatter.calendar = calendar + return dateComponentsFormatter.string(from: duration) ?? "" + } + } + + static func formattedRelativeTime(_ timestampMs: Int64, minimumUnit: NSCalendar.Unit) -> String { + let relativeTimestamp: TimeInterval = Date().timeIntervalSince1970 - TimeInterval(timestampMs) / 1000 + return relativeTimestamp.formatted(format: .short, minimumUnit: minimumUnit) + } +} diff --git a/SessionUtilitiesKit/General/TimeInterval+Utilities.swift b/SessionUIKit/Utilities/TimeInterval+Utilities.swift similarity index 100% rename from SessionUtilitiesKit/General/TimeInterval+Utilities.swift rename to SessionUIKit/Utilities/TimeInterval+Utilities.swift diff --git a/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift b/SessionUIKit/Utilities/UICollectionView+ReusableView.swift similarity index 100% rename from SessionUtilitiesKit/General/UICollectionView+ReusableView.swift rename to SessionUIKit/Utilities/UICollectionView+ReusableView.swift diff --git a/SessionUtilitiesKit/General/UIEdgeInsets.swift b/SessionUIKit/Utilities/UIEdgeInsets+Utilities.swift similarity index 54% rename from SessionUtilitiesKit/General/UIEdgeInsets.swift rename to SessionUIKit/Utilities/UIEdgeInsets+Utilities.swift index f904b923e1..27e81a0f35 100644 --- a/SessionUtilitiesKit/General/UIEdgeInsets.swift +++ b/SessionUIKit/Utilities/UIEdgeInsets+Utilities.swift @@ -5,12 +5,12 @@ extension UIEdgeInsets { self.init(top: value, left: value, bottom: value, right: value) } - public init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { + @MainActor public init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { self.init( top: top, - left: (Dependencies.isRTL ? trailing : leading), + left: (SNUIKit.isRTL ? trailing : leading), bottom: bottom, - right: (Dependencies.isRTL ? leading : trailing) + right: (SNUIKit.isRTL ? leading : trailing) ) } } diff --git a/SessionUtilitiesKit/General/UITableView+ReusableView.swift b/SessionUIKit/Utilities/UITableView+ReusableView.swift similarity index 100% rename from SessionUtilitiesKit/General/UITableView+ReusableView.swift rename to SessionUIKit/Utilities/UITableView+ReusableView.swift diff --git a/SessionUIKit/Utilities/UTType+Localization.swift b/SessionUIKit/Utilities/UTType+Localization.swift new file mode 100644 index 0000000000..6529c4e1c1 --- /dev/null +++ b/SessionUIKit/Utilities/UTType+Localization.swift @@ -0,0 +1,15 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import UniformTypeIdentifiers + +public extension UTType { + func shortDescription(isVoiceMessage: Bool) -> String { + if conforms(to: .image) { return "image".localized() } + if conforms(to: .audio) && isVoiceMessage { return "messageVoice".localized() } + if conforms(to: .audio) { return "audio".localized() } + if conforms(to: .video) || conforms(to: .movie) { return "video".localized() } + + return "document".localized() + } +} diff --git a/SessionUIKitTests/SessionUIKit.xctestplan b/SessionUIKitTests/SessionUIKit.xctestplan new file mode 100644 index 0000000000..934e5943e8 --- /dev/null +++ b/SessionUIKitTests/SessionUIKit.xctestplan @@ -0,0 +1,25 @@ +{ + "configurations" : [ + { + "id" : "C7832D05-760A-4AEA-8F36-05204D374CFF", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Session.xcodeproj", + "identifier" : "FD9E26B72EA72D3E00404C7F", + "name" : "SessionUIKitTests" + } + } + ], + "version" : 1 +} diff --git a/SessionUtilitiesKitTests/Utilities/StringUtilitiesSpec.swift b/SessionUIKitTests/Utilities/SUIKStringUtilitiesSpec.swift similarity index 97% rename from SessionUtilitiesKitTests/Utilities/StringUtilitiesSpec.swift rename to SessionUIKitTests/Utilities/SUIKStringUtilitiesSpec.swift index 4af25c563b..c618aec1d5 100644 --- a/SessionUtilitiesKitTests/Utilities/StringUtilitiesSpec.swift +++ b/SessionUIKitTests/Utilities/SUIKStringUtilitiesSpec.swift @@ -5,7 +5,7 @@ import Foundation import Quick import Nimble -@testable import SessionUtilitiesKit +@testable import SessionUIKit class StringUtilitiesSpec: QuickSpec { override class func spec() { diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 09817afb74..5a1990bdd7 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -1487,3 +1487,15 @@ func isDebuggerAttached() -> Bool { return false #endif } + +private extension String.StringInterpolation { + mutating func appendInterpolation(_ value: Double, format: String, omitZeroDecimal: Bool = false) { + guard !omitZeroDecimal || Int(exactly: value) == nil else { + appendLiteral("\(Int(exactly: value)!)") + return + } + + let result: String = String(format: "%\(format)f", value) + appendLiteral(result) + } +} diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index fefcc78e19..d35f2e279c 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -587,7 +587,7 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs // MARK: - Functions - fileprivate func load(_ target: PagedData.InternalTarget) { + fileprivate func load(_ target: PagedData.InternalTarget, onComplete: (() -> ())?) { // Only allow a single page load at a time guard !self.isLoadingMoreData else { return } @@ -766,7 +766,10 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs (targetIndex > (cacheCurrentEndIndex + currentPageInfo.pageSize)) else { let callback: () -> () = { - self?.load(.untilInclusive(id: targetId, padding: paddingForInclusive)) + self?.load( + .untilInclusive(id: targetId, padding: paddingForInclusive), + onComplete: onComplete + ) } return (nil, callback) } @@ -777,7 +780,7 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs self?.dataCache = DataCache() self?.associatedRecords.forEach { $0.clearCache() } self?.pageInfo = PagedData.PageInfo(pageSize: currentPageInfo.pageSize) - self?.load(.initialPageAround(id: targetId)) + self?.load(.initialPageAround(id: targetId), onComplete: onComplete) } return (nil, callback) @@ -919,6 +922,7 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs /// via the `PagedData.processAndTriggerUpdates` function) self.onChangeUnsorted(self.dataCache.values, loadedPage.pageInfo) self.isLoadingMoreData = false + onComplete?() } ) } @@ -929,7 +933,7 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs public func resume() { self.isSuspended = false - self.load(.reloadCurrent) + self.load(.reloadCurrent, onComplete: nil) } } @@ -970,12 +974,12 @@ private extension PagedData.Target { } public extension PagedDatabaseObserver { - func load(_ target: PagedData.Target) where ObservedTable.ID: SQLExpressible { - self.load(target.internalTarget) + func load(_ target: PagedData.Target, onComplete: (() -> ())? = nil) where ObservedTable.ID: SQLExpressible { + self.load(target.internalTarget, onComplete: onComplete) } - func load(_ target: PagedData.Target) where ObservedTable.ID == Optional, ID: SQLExpressible { - self.load(target.internalTarget) + func load(_ target: PagedData.Target, onComplete: (() -> ())? = nil) where ObservedTable.ID == Optional, ID: SQLExpressible { + self.load(target.internalTarget, onComplete: onComplete) } } diff --git a/SessionUtilitiesKit/General/CallRingTonePlayer.swift b/SessionUtilitiesKit/General/CallRingTonePlayer.swift index dde0cb87ed..bd1916932c 100644 --- a/SessionUtilitiesKit/General/CallRingTonePlayer.swift +++ b/SessionUtilitiesKit/General/CallRingTonePlayer.swift @@ -26,7 +26,7 @@ public final class CallRingTonePlayer { player?.numberOfLoops = -1 player?.play() } catch let error { - print(error.localizedDescription) + Log.warn("[Calls] Failed to start playing ringtone due to error: \(error.localizedDescription)") } } diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 64dc28534c..8cf1be3fb2 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -746,7 +746,7 @@ public actor Logger: LoggerType { matches.forEach { match in guard let matchRange: Range = Range(match.range, in: text) else { return } - updatedText.replaceSubrange(matchRange, with: String(text[matchRange]).truncated()) + updatedText.replaceSubrange(matchRange, with: String(text[matchRange]).logTruncated()) } return updatedText @@ -976,4 +976,10 @@ private extension String { func then(_ transform: (String) -> String) -> String { return transform(self) } + + func logTruncated(prefix: Int = 4, suffix: Int = 4) -> String { + guard count > (prefix + suffix) else { return self } + + return "\(self.prefix(prefix))...\(self.suffix(suffix))" + } } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 17bb313d61..c74bb33b38 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -72,94 +72,6 @@ public extension String { } } -// MARK: - Formatting - -public extension String.StringInterpolation { - mutating func appendInterpolation(_ value: TimeUnit, unit: TimeUnit.Unit, resolution: Int = 2) { - appendLiteral("\(TimeUnit(value, unit: unit, resolution: resolution))") - } - - mutating func appendInterpolation(_ value: Int, format: String) { - let result: String = String(format: "%\(format)d", value) - appendLiteral(result) - } - - mutating func appendInterpolation(_ value: Double, format: String, omitZeroDecimal: Bool = false) { - guard !omitZeroDecimal || Int(exactly: value) == nil else { - appendLiteral("\(Int(exactly: value)!)") - return - } - - let result: String = String(format: "%\(format)f", value) - appendLiteral(result) - } -} - -public extension String { - static func formattedDuration(_ duration: TimeInterval, format: TimeInterval.DurationFormat = .short, minimumUnit: NSCalendar.Unit = .second) -> String { - let dateComponentsFormatter = DateComponentsFormatter() - var allowedUnits: NSCalendar.Unit = [.weekOfMonth, .day, .hour, .minute, .second] - switch minimumUnit { - case .minute: - allowedUnits.remove(.second) - default: - break - } - dateComponentsFormatter.allowedUnits = allowedUnits - var calendar = Calendar.current - - switch format { - case .videoDuration: - guard duration < 3600 else { fallthrough } - dateComponentsFormatter.allowedUnits = [.minute, .second] - dateComponentsFormatter.unitsStyle = .positional - dateComponentsFormatter.zeroFormattingBehavior = .pad - return dateComponentsFormatter.string(from: duration) ?? "" - - case .hoursMinutesSeconds: - if duration < 3600 { - dateComponentsFormatter.allowedUnits = [.minute, .second] - dateComponentsFormatter.zeroFormattingBehavior = .pad - } else { - dateComponentsFormatter.allowedUnits = [.hour, .minute, .second] - dateComponentsFormatter.zeroFormattingBehavior = .default - } - dateComponentsFormatter.unitsStyle = .positional - // This is a workaroud for 00:00 to be shown as 0:00 - var str: String = dateComponentsFormatter.string(from: duration) ?? "" - if str.hasPrefix("0") { - str.remove(at: str.startIndex) - } - return str - - case .short: // Single unit, no localization, short version e.g. 1w - dateComponentsFormatter.maximumUnitCount = 1 - dateComponentsFormatter.unitsStyle = .abbreviated - calendar.locale = Locale(identifier: "en-US") - dateComponentsFormatter.calendar = calendar - return dateComponentsFormatter.string(from: duration) ?? "" - - case .long: // Single unit, long version e.g. 1 week - dateComponentsFormatter.maximumUnitCount = 1 - dateComponentsFormatter.unitsStyle = .full - return dateComponentsFormatter.string(from: duration) ?? "" - - case .twoUnits: // 2 units, no localization, short version e.g 1w 1d, remove trailing 0's e.g 12h 0m -> 12h - dateComponentsFormatter.maximumUnitCount = 2 - dateComponentsFormatter.unitsStyle = .abbreviated - dateComponentsFormatter.zeroFormattingBehavior = .dropAll - calendar.locale = Locale(identifier: "en-US") - dateComponentsFormatter.calendar = calendar - return dateComponentsFormatter.string(from: duration) ?? "" - } - } - - static func formattedRelativeTime(_ timestampMs: Int64, minimumUnit: NSCalendar.Unit) -> String { - let relativeTimestamp: TimeInterval = Date().timeIntervalSince1970 - TimeInterval(timestampMs) / 1000 - return relativeTimestamp.formatted(format: .short, minimumUnit: minimumUnit) - } -} - // MARK: - Unicode Handling private extension CharacterSet { @@ -327,16 +239,3 @@ public extension String { return filtered } } - -// MARK: - Truncation - -public extension String { - /// A standardised mechanism for truncating a user id - /// - /// stringlint:ignore_contents - func truncated(prefix: Int = 4, suffix: Int = 4) -> String { - guard count > (prefix + suffix) else { return self } - - return "\(self.prefix(prefix))...\(self.suffix(suffix))" - } -} diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index e3549638e3..d936a10d97 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -1670,7 +1670,7 @@ public final class JobQueue: Hashable { if executionType != .concurrent || currentlyRunningJobIds.isEmpty { let timingString: String = (nextJobTimestamp == 0 ? "that should be in the queue" : - "scheduled \(.seconds(secondsUntilNextJob), unit: .s) ago" + "scheduled \(secondsUntilNextJob)s ago" ) Log.info(.jobRunner, "Restarting \(queueContext) immediately for job \(timingString)") } @@ -1688,7 +1688,7 @@ public final class JobQueue: Hashable { guard executionType == .concurrent || currentlyRunningJobIds.isEmpty else { return } // Setup a trigger - Log.info(.jobRunner, "Stopping \(queueContext) until next job in \(.seconds(secondsUntilNextJob), unit: .s)") + Log.info(.jobRunner, "Stopping \(queueContext) until next job in \(secondsUntilNextJob)s") _nextTrigger.performUpdate { trigger in trigger?.invalidate() // Need to invalidate the old trigger to prevent a memory leak return Trigger.create(queue: self, timestamp: nextJobTimestamp, using: dependencies) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift index fb189e32a6..5962a48bb0 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift @@ -3,6 +3,7 @@ import Foundation import UIKit import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit protocol AttachmentApprovalInputAccessoryViewDelegate: AnyObject { @@ -17,6 +18,7 @@ class AttachmentApprovalInputAccessoryView: UIView { let attachmentTextToolbar: AttachmentTextToolbar let galleryRailView: GalleryRailView + var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var isEditingMediaMessage: Bool { return attachmentTextToolbar.inputView?.isFirstResponder ?? false diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 2cbef2e093..2f843bf0cb 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -69,6 +69,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let threadId: String private let threadVariant: SessionThread.Variant private let isAddMoreVisible: Bool + private var quoteDraft: QuoteViewModel? private var isSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } @@ -142,6 +143,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC threadId: String, threadVariant: SessionThread.Variant, attachments: [PendingAttachment], + quoteDraft: QuoteViewModel?, disableLinkPreviewImageDownload: Bool, didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies @@ -155,6 +157,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC let attachmentItems = attachments.map { PendingAttachmentRailItem(attachment: $0, using: dependencies) } + self.quoteDraft = quoteDraft self.isAddMoreVisible = (mode == .sharedNavigation) self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload self.didLoadLinkPreview = didLoadLinkPreview @@ -187,6 +190,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC threadId: String, threadVariant: SessionThread.Variant, attachments: [PendingAttachment], + quoteDraft: QuoteViewModel?, approvalDelegate: AttachmentApprovalViewControllerDelegate, disableLinkPreviewImageDownload: Bool, didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, @@ -197,6 +201,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC threadId: threadId, threadVariant: threadVariant, attachments: attachments, + quoteDraft: quoteDraft, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, didLoadLinkPreview: didLoadLinkPreview, using: dependencies @@ -212,6 +217,18 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let kSpacingBetweenItems: CGFloat = 20 + lazy var footerControlsStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + galleryRailView, + snInputView + ]) + result.axis = .vertical + result.alignment = .fill + result.distribution = .fill + + return result + }() + private lazy var bottomToolView: AttachmentApprovalInputAccessoryView = { let bottomToolView = AttachmentApprovalInputAccessoryView(delegate: self, using: dependencies) bottomToolView.delegate = self @@ -222,6 +239,35 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC }() private var galleryRailView: GalleryRailView { return bottomToolView.galleryRailView } + + private lazy var snInputView: InputView = InputView( + delegate: self, + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: threadVariant, + using: dependencies + ), + using: dependencies + ) + + lazy var inputBackgroundView: UIView = { + let result: UIView = UIView() + + let backgroundView: UIView = UIView() + backgroundView.themeBackgroundColor = .backgroundSecondary + backgroundView.alpha = Values.lowOpacity + result.addSubview(backgroundView) + backgroundView.pin(to: result) + + let blurView: UIVisualEffectView = UIVisualEffectView() + result.addSubview(blurView) + blurView.pin(to: result) + + ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _, _ in + blurView?.effect = UIBlurEffect(style: theme.blurStyle) + } + + return result + }() private lazy var pagerScrollView: UIScrollView? = { // This is kind of a hack. Since we don't have first class access to the superview's `scrollView` @@ -653,9 +699,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, - beforePresented: { [weak self] in - self?.hideInputAccessoryView() - }, afterClosed: { [weak self] in self?.showInputAccessoryView() self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") @@ -694,9 +737,6 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, - beforePresented: { [weak self] in - self?.hideInputAccessoryView() - }, afterClosed: { [weak self] in self?.showInputAccessoryView() self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 0f9a2c430c..16c065091b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -140,7 +140,7 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) // Bottom stack view - let bottomStackView = UIStackView(arrangedSubviews: [ inputTextView, container(for: sendButton) ]) + let bottomStackView = UIStackView(arrangedSubviews: [ inputTextView, InputViewButton.container(for: sendButton) ]) bottomStackView.axis = .horizontal bottomStackView.spacing = Values.smallSpacing bottomStackView.alignment = .center @@ -180,18 +180,6 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { @objc private func characterLimitLabelTapped() { delegate?.attachmentTextToolBarDidTapCharacterLimitLabel(self) } - - // MARK: - Convenience - - private func container(for button: InputViewButton) -> UIView { - let result: UIView = UIView() - result.addSubview(button) - result.set(.width, to: InputViewButton.expandedSize) - result.set(.height, to: InputViewButton.expandedSize) - button.center(in: result) - - return result - } } extension AttachmentTextToolbar: InputViewButtonDelegate { diff --git a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift index 4b6ea451ae..8109085fbd 100644 --- a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift @@ -76,7 +76,7 @@ public extension UIViewController { // in a UIBarButtonItem. backButton.addTarget(target, action: selector, for: .touchUpInside) - let config: UIImage.Configuration = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium) + let config: UIImage.Configuration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold) backButton.setImage( UIImage(systemName: "chevron.backward", withConfiguration: config)? .withRenderingMode(.alwaysTemplate), @@ -84,7 +84,7 @@ public extension UIViewController { ) backButton.themeTintColor = .textPrimary backButton.contentHorizontalAlignment = .left - backButton.imageEdgeInsets = UIEdgeInsets(top: 0, leading: extraLeftPadding, bottom: 0, trailing: 0) + backButton.imageEdgeInsets = UIEdgeInsets(top: 4, leading: extraLeftPadding, bottom: -4, trailing: 0) backButton.frame = CGRect( x: 0, y: 0, From 7d461daec2c61481de6acb739377493945ca6d2c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 31 Oct 2025 10:50:19 +1100 Subject: [PATCH 2/3] Refactoring LinkPreview to work within SessionUIKit --- Session.xcodeproj/project.pbxproj | 24 +- .../ConversationVC+Interaction.swift | 16 +- .../Conversations/ConversationViewModel.swift | 14 +- .../Database/Models/LinkPreview.swift | 390 +----------------- .../Link Previews/HTMLMetadata.swift | 2 +- .../Types/LinkPreviewManager.swift | 351 ++++++++++++++++ SessionShareExtension/ThreadPickerVC.swift | 20 +- .../ThreadPickerViewModel.swift | 6 +- .../Components/Input View/InputView.swift | 184 +++++---- SessionUIKit/Components/LinkPreviewView.swift | 7 + SessionUIKit/Types/LinkPreviewDraft.swift | 22 - .../Types/LinkPreviewManagerType.swift | 25 ++ SessionUIKit/Utilities/Task+Utilities.swift | 26 ++ .../AttachmentApprovalViewController.swift | 35 +- .../AttachmentPrepViewController.swift | 4 +- .../MediaMessageView.swift | 39 +- 16 files changed, 598 insertions(+), 567 deletions(-) create mode 100644 SessionMessagingKit/Types/LinkPreviewManager.swift delete mode 100644 SessionUIKit/Types/LinkPreviewDraft.swift create mode 100644 SessionUIKit/Types/LinkPreviewManagerType.swift create mode 100644 SessionUIKit/Utilities/Task+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 42a710749e..fb0fbdf2e0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -948,7 +948,9 @@ FDAA36B22EB2D2F60040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B12EB2D2F60040603E /* NVActivityIndicatorView */; }; FDAA36B42EB2DFA30040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B32EB2DFA30040603E /* NVActivityIndicatorView */; }; FDAA36B72EB2E55C0040603E /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; - FDAB8A812EB2A45D000A6C65 /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; + FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */; }; + FDAA36BC2EB3FC980040603E /* LinkPreviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */; }; + FDAA36BE2EB3FFB50040603E /* Task+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BD2EB3FFB10040603E /* Task+Utilities.swift */; }; FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; FDAB8A852EB2BC37000A6C65 /* MentionSelectionView+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; @@ -1710,7 +1712,6 @@ C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDB3A255A580B00E217F9 /* PollerType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollerType.swift; sourceTree = ""; }; C33FDB3F255A580C00E217F9 /* String+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+SSK.swift"; sourceTree = ""; }; - C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = ""; }; C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = ""; }; @@ -2224,6 +2225,9 @@ FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Sound.swift"; sourceTree = ""; }; FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationPreviewType.swift"; sourceTree = ""; }; + FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewManagerType.swift; sourceTree = ""; }; + FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewManager.swift; sourceTree = ""; }; + FDAA36BD2EB3FFB10040603E /* Task+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Utilities.swift"; sourceTree = ""; }; FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionSelectionView+SessionMessagingKit.swift"; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; @@ -3368,6 +3372,7 @@ FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */, + FDAA36BD2EB3FFB10040603E /* Task+Utilities.swift */, FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */, FDE754BD2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift */, FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */, @@ -3802,6 +3807,7 @@ C300A5F02554B08500555489 /* Sending & Receiving */, FD8ECF7529340F4800C0D1BB /* LibSession */, FD3E0C82283B581F002A425C /* Shared Models */, + FDAA36BA2EB3FC8C0040603E /* Types */, C3BBE0B32554F0D30050F1E3 /* Utilities */, FD245C612850664300B966DD /* Configuration.swift */, ); @@ -4644,7 +4650,7 @@ FDE6E99729F8E63A00F93C5D /* Accessibility.swift */, FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, - C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */, + FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */, @@ -4966,6 +4972,14 @@ path = Models; sourceTree = ""; }; + FDAA36BA2EB3FC8C0040603E /* Types */ = { + isa = PBXGroup; + children = ( + FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */, + ); + path = Types; + sourceTree = ""; + }; FDB5DAD22A9483D4002C8721 /* Group Update Messages */ = { isa = PBXGroup; children = ( @@ -6314,6 +6328,7 @@ files = ( FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, + FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, @@ -6376,7 +6391,6 @@ FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */, 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, - FDAB8A812EB2A45D000A6C65 /* LinkPreviewDraft.swift in Sources */, FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, FD42ECD02E289261002D03EA /* ThemeLinearGradient.swift in Sources */, FDE754BE2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift in Sources */, @@ -6406,6 +6420,7 @@ 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */, FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */, + FDAA36BE2EB3FFB50040603E /* Task+Utilities.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, FD8A5B0A2DBF246A004C689B /* Constants.swift in Sources */, C331FF9A2558FA6B00070591 /* Values.swift in Sources */, @@ -6816,6 +6831,7 @@ FDD23AEA2E458EB00057E853 /* _012_AddJobPriority.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, + FDAA36BC2EB3FC980040603E /* LinkPreviewManager.swift in Sources */, FDD23AEC2E458F980057E853 /* _024_ResetUserConfigLastHashes.swift in Sources */, FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, FDD23AE82E458DD40057E853 /* _002_SUK_SetupStandardJobs.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2cdf2c344d..1d5475cbbd 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -652,8 +652,8 @@ extension ConversationVC: sendMessage( text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - linkPreviewDraft: snInputView.linkPreviewInfo?.draft, - quoteViewModel: snInputView.quoteDraft + linkPreviewViewModel: snInputView.linkPreviewViewModel, + quoteViewModel: snInputView.quoteViewModel ) } @@ -689,7 +689,7 @@ extension ConversationVC: @MainActor func sendMessage( text: String, attachments: [PendingAttachment] = [], - linkPreviewDraft: LinkPreviewDraft? = nil, + linkPreviewViewModel: LinkPreviewViewModel? = nil, quoteViewModel: QuoteViewModel? = nil, hasPermissionToSendSeed: Bool = false ) { @@ -725,7 +725,7 @@ extension ConversationVC: self?.sendMessage( text: text, attachments: attachments, - linkPreviewDraft: linkPreviewDraft, + linkPreviewViewModel: linkPreviewViewModel, quoteViewModel: quoteViewModel, hasPermissionToSendSeed: true ) @@ -754,7 +754,7 @@ extension ConversationVC: text: processedText, sentTimestampMs: sentTimestampMs, attachments: attachments, - linkPreviewDraft: linkPreviewDraft, + linkPreviewViewModel: linkPreviewViewModel, quoteViewModel: quoteViewModel ) await approveMessageRequestIfNeeded( @@ -794,7 +794,7 @@ extension ConversationVC: // If there is a LinkPreview draft then check the state of any existing link previews and // insert a new one if needed - if let linkPreviewDraft: LinkPreviewDraft = optimisticData.linkPreviewDraft { + if let linkPreviewViewModel: LinkPreviewViewModel = optimisticData.linkPreviewViewModel { let invalidLinkPreviewAttachmentStates: [Attachment.State] = [ .failedDownload, .pendingDownload, .downloading, .failedUpload, .invalid ] @@ -815,8 +815,8 @@ extension ConversationVC: // If we don't have a "valid" existing link preview then upsert a new one if invalidLinkPreviewAttachmentStates.contains(linkPreviewAttachmentState) { try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, + url: linkPreviewViewModel.urlString, + title: linkPreviewViewModel.title, attachmentId: try optimisticData.linkPreviewPreparedAttachment? .attachment .inserted(db) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index aca7d42910..42113cbdbc 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -712,7 +712,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold messageViewModel: MessageViewModel, interaction: Interaction, attachmentData: [Attachment]?, - linkPreviewDraft: LinkPreviewDraft?, + linkPreviewViewModel: LinkPreviewViewModel?, linkPreviewPreparedAttachment: PreparedAttachment?, quoteViewModel: QuoteViewModel? ) @@ -724,7 +724,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold text: String?, sentTimestampMs: Int64, attachments: [PendingAttachment]?, - linkPreviewDraft: LinkPreviewDraft?, + linkPreviewViewModel: LinkPreviewViewModel?, quoteViewModel: QuoteViewModel? ) async -> OptimisticMessageData { // Generate the optimistic data @@ -745,7 +745,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold body: text ), expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), - linkPreviewUrl: linkPreviewDraft?.urlString, + linkPreviewUrl: linkPreviewViewModel?.urlString, isProMessage: dependencies[cache: .libSession].isSessionPro, using: dependencies ) @@ -759,7 +759,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - if let draft: LinkPreviewDraft = linkPreviewDraft { + if let draft: LinkPreviewViewModel = linkPreviewViewModel { linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( urlString: draft.urlString, imageSource: draft.imageSource, @@ -802,7 +802,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold }(), currentUserProfile: currentUserProfile, quoteViewModel: quoteViewModel,//MessageViewModel.QuotedInfo(replyModel: quoteModel), - linkPreview: linkPreviewDraft.map { draft in + linkPreview: linkPreviewViewModel.map { draft in LinkPreview( url: draft.urlString, title: draft.title, @@ -818,7 +818,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold messageViewModel, interaction, optimisticAttachments, - linkPreviewDraft, + linkPreviewViewModel, linkPreviewPreparedAttachment, quoteViewModel ) @@ -842,7 +842,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), $0.interaction, $0.attachmentData, - $0.linkPreviewDraft, + $0.linkPreviewViewModel, $0.linkPreviewPreparedAttachment, $0.quoteViewModel ) diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 9f53541edb..bfd479f168 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -85,7 +85,7 @@ public extension LinkPreview { init?(_ db: ObservingDatabase, proto: SNProtoDataMessage, sentTimestampMs: TimeInterval) throws { guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } - guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } + guard LinkPreviewManager.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } // Try to get an existing link preview first let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) @@ -102,7 +102,7 @@ public extension LinkPreview { self.url = previewProto.url self.timestamp = timestamp self.variant = .standard - self.title = LinkPreview.normalizeTitle(title: previewProto.title) + self.title = LinkPreviewManager.normalizeTitle(title: previewProto.title) if let imageProto = previewProto.image { let attachment: Attachment = Attachment(proto: imageProto) @@ -122,11 +122,6 @@ public extension LinkPreview { // MARK: - Convenience public extension LinkPreview { - struct URLMatchResult { - let urlString: String - let matchRange: NSRange - } - static func timestampFor(sentTimestampMs: Double) -> TimeInterval { // We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler // than 86,400) to optimise LinkPreview storage without having too stale data @@ -160,385 +155,4 @@ public extension LinkPreview { using: dependencies ) } - - static func isValidLinkUrl(_ urlString: String) -> Bool { - return URL(string: urlString) != nil - } - - static func allPreviewUrls(forMessageBodyText body: String) -> [String] { - return allPreviewUrlMatches(forMessageBodyText: body).map { $0.urlString } - } - - // MARK: - Private Methods - - private static func allPreviewUrlMatches(forMessageBodyText body: String) -> [URLMatchResult] { - let detector: NSDataDetector - do { - detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - } - catch { - return [] - } - - var urlMatches: [URLMatchResult] = [] - let matches = detector.matches(in: body, options: [], range: NSRange(location: 0, length: body.count)) - for match in matches { - guard let matchURL = match.url else { continue } - - // If the URL entered didn't have a scheme it will default to 'http', we want to catch this and - // set the scheme to 'https' instead as we don't load previews for 'http' so this will result - // in more previews actually getting loaded without forcing the user to enter 'https://' before - // every URL they enter - let urlString: String = (matchURL.absoluteString == "http://\(body)" ? - "https://\(body)" : - matchURL.absoluteString - ) - - if isValidLinkUrl(urlString) { - let matchResult = URLMatchResult(urlString: urlString, matchRange: match.range) - urlMatches.append(matchResult) - } - } - - return urlMatches - } - - fileprivate static func normalizeTitle(title: String?) -> String? { - guard var result: String = title, !result.isEmpty else { return nil } - - // Truncate title after 2 lines of text. - let maxLineCount = 2 - var components = result.components(separatedBy: .newlines) - - if components.count > maxLineCount { - components = Array(components[0.. maxCharacterCount { - let endIndex = result.index(result.startIndex, offsetBy: maxCharacterCount) - result = String(result[.. = LRUCache() - - static func previewUrl( - for body: String?, - selectedRange: NSRange? = nil, - using dependencies: Dependencies - ) -> String? { - guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { return nil } - guard let body: String = body else { return nil } - - if let cachedUrl = _previewUrlCache.performMap({ $0.get(key: body) }) { - guard cachedUrl.count > 0 else { - return nil - } - - return cachedUrl - } - - let previewUrlMatches: [URLMatchResult] = allPreviewUrlMatches(forMessageBodyText: body) - - guard let urlMatch: URLMatchResult = previewUrlMatches.first else { - // Use empty string to indicate "no preview URL" in the cache. - _previewUrlCache.performUpdate { $0.settingObject("", forKey: body) } - return nil - } - - if let selectedRange: NSRange = selectedRange { - let cursorAtEndOfMatch: Bool = ( - (urlMatch.matchRange.location + urlMatch.matchRange.length) == selectedRange.location - ) - - if selectedRange.location != body.count, (urlMatch.matchRange.intersection(selectedRange) != nil || cursorAtEndOfMatch) { - // we don't want to cache the result here, as we want to fetch the link preview - // if the user moves the cursor. - return nil - } - } - - _previewUrlCache.performUpdate { - $0.settingObject(urlMatch.urlString, forKey: body) - } - - return urlMatch.urlString - } -} - -// MARK: - Drafts - -public extension LinkPreview { - private struct Contents { - public var title: String? - public var imageUrl: String? - - public init(title: String?, imageUrl: String? = nil) { - self.title = title - self.imageUrl = imageUrl - } - } - - private static let serialQueue = DispatchQueue(label: "org.signal.linkPreview") - - // This cache should only be accessed on serialQueue. - // - // We should only maintain a "cache" of the last known draft. - private static var linkPreviewDraftCache: LinkPreviewDraft? - - // Twitter doesn't return OpenGraph tags to Signal - // `curl -A Signal "https://twitter.com/signalapp/status/1280166087577997312?s=20"` - // If this ever changes, we can switch back to our default User-Agent - private static let userAgentString = "WhatsApp" - - private static func cachedLinkPreview(forPreviewUrl previewUrl: String) -> LinkPreviewDraft? { - return serialQueue.sync { - guard let linkPreviewDraft = linkPreviewDraftCache, - linkPreviewDraft.urlString == previewUrl else { - return nil - } - return linkPreviewDraft - } - } - - private static func setCachedLinkPreview( - _ linkPreviewDraft: LinkPreviewDraft, - forPreviewUrl previewUrl: String, - using dependencies: Dependencies - ) { - assert(previewUrl == linkPreviewDraft.urlString) - - // Exit early if link previews are not enabled in order to avoid - // tainting the cache. - guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { return } - - serialQueue.sync { - linkPreviewDraftCache = linkPreviewDraft - } - } - - static func tryToBuildPreviewInfo( - previewUrl: String?, - skipImageDownload: Bool, - using dependencies: Dependencies - ) async throws -> LinkPreviewDraft { - guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { - throw LinkPreviewError.featureDisabled - } - - // Force the url to lowercase to ensure we casing doesn't result in redownloading the - // details - guard let previewUrl: String = previewUrl?.lowercased() else { - throw LinkPreviewError.invalidInput - } - - if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { - return cachedInfo - } - - let (data, response) = try await downloadLink(url: previewUrl) - try Task.checkCancellation() /// No use trying to parse and potentially download an image if the task was cancelled - - let linkPreviewDraft: LinkPreviewDraft = try await parseLinkDataAndBuildDraft( - linkData: data, - response: response, - linkUrlString: previewUrl, - skipImageDownload: skipImageDownload, - using: dependencies - ) - - guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } - - setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl, using: dependencies) - - return linkPreviewDraft - } - - private static func downloadLink( - url urlString: String, - remainingRetries: UInt = 3 - ) async throws -> (Data, URLResponse) { - Log.verbose("[LinkPreview] Download url: \(urlString)") - - // let sessionConfiguration = ContentProxy.sessionConfiguration() // Loki: Signal's proxy appears to have been banned by YouTube - let sessionConfiguration = URLSessionConfiguration.ephemeral - - // Don't use any caching to protect privacy of these requests. - sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData - sessionConfiguration.urlCache = nil - - guard - var request: URLRequest = URL(string: urlString).map({ URLRequest(url: $0) }), - request.url?.scheme != nil, - (request.url?.host ?? "").isEmpty == false, - ContentProxy.configureProxiedRequest(request: &request) - else { throw LinkPreviewError.assertionFailure } - - request.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") // Set a fake value - - let session: URLSession = URLSession(configuration: sessionConfiguration) - - do { - let (data, response) = try await session.data(for: request) - - guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { - throw LinkPreviewError.assertionFailure - } - - if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { - guard contentType.lowercased().hasPrefix("text/") else { - throw LinkPreviewError.invalidContent - } - } - - guard data.count > 0 else { throw LinkPreviewError.invalidContent } - - return (data, response) - } - catch { - guard isRetryable(error: error), remainingRetries > 0 else { - throw LinkPreviewError.couldNotDownload - } - - return try await LinkPreview.downloadLink( - url: urlString, - remainingRetries: (remainingRetries - 1) - ) - } - } - - private static func parseLinkDataAndBuildDraft( - linkData: Data, - response: URLResponse, - linkUrlString: String, - skipImageDownload: Bool, - using dependencies: Dependencies - ) async throws -> LinkPreviewDraft { - let contents: LinkPreview.Contents = try parse(linkData: linkData, response: response) - let title: String? = contents.title - - /// If we don't want to download the image then just return the non-image content - guard !skipImageDownload else { - return LinkPreviewDraft(urlString: linkUrlString, title: title) - } - - do { - /// If the image isn't valid then just return the non-image content - let imageUrl: URL = try contents.imageUrl.map({ URL(string: $0) }) ?? { - throw LinkPreviewError.invalidContent - }() - let imageSource: ImageDataManager.DataSource = try await downloadImage( - url: imageUrl, - using: dependencies - ) - - return LinkPreviewDraft(urlString: linkUrlString, title: title, imageSource: imageSource) - } - catch { - return LinkPreviewDraft(urlString: linkUrlString, title: title) - } - } - - private static func parse(linkData: Data, response: URLResponse) throws -> Contents { - guard let linkText = String(bytes: linkData, encoding: response.stringEncoding ?? .utf8) else { - Log.verbose("[LinkPreview] Could not parse link text.") - throw LinkPreviewError.invalidInput - } - - let content = HTMLMetadata.construct(parsing: linkText) - - var title: String? - let rawTitle = content.ogTitle ?? content.titleTag - if - let decodedTitle: String = decodeHTMLEntities(inString: rawTitle ?? ""), - let normalizedTitle: String = LinkPreview.normalizeTitle(title: decodedTitle), - normalizedTitle.count > 0 - { - title = normalizedTitle - } - - Log.verbose("[LinkPreview] Title: \(String(describing: title))") - - guard let rawImageUrlString = content.ogImageUrlString ?? content.faviconUrlString else { - return Contents(title: title) - } - guard let imageUrlString = decodeHTMLEntities(inString: rawImageUrlString)?.stripped else { - return Contents(title: title) - } - - return Contents(title: title, imageUrl: imageUrlString) - } - - private static func downloadImage( - url: URL, - using dependencies: Dependencies - ) async throws -> ImageDataManager.DataSource { - guard let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( - url: url as NSURL - ) else { throw LinkPreviewError.invalidInput } - - do { - let asset: ProxiedContentAsset = try await dependencies[singleton: .proxiedContentDownloader] - .requestAsset( - assetDescription: assetDescription, - priority: .high, - shouldIgnoreSignalProxy: true - ) - let pendingAttachment: PendingAttachment = PendingAttachment( - source: .media(.url(URL(fileURLWithPath: asset.filePath))), - using: dependencies - ) - let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( - operations: [.convert(to: .webPLossy(maxDimension: 1024))], - using: dependencies - ) - - return .url(URL(fileURLWithPath: preparedAttachment.filePath)) - } - catch { throw LinkPreviewError.couldNotDownload } - } - - private static func isRetryable(error: Error) -> Bool { - if (error as NSError).domain == kCFErrorDomainCFNetwork as String { - // Network failures are retried. - return true - } - - return false - } - - private static func fileExtension(forImageUrl urlString: String) -> String? { - guard let imageUrl = URL(string: urlString) else { return nil } - - let imageFilename = imageUrl.lastPathComponent - let imageFileExtension = (imageFilename as NSString).pathExtension.lowercased() - - guard imageFileExtension.count > 0 else { - // TODO: For those links don't have a file extension, we should figure out a way to know the image mime type - return UTType.fileExtensionDefaultImage - } - - return imageFileExtension - } - - private static func decodeHTMLEntities(inString value: String) -> String? { - guard let data = value.data(using: .utf8) else { return nil } - - let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue - ] - - guard let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) else { - return nil - } - - return attributedString.string - } } diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/HTMLMetadata.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/HTMLMetadata.swift index c14977a91d..5fcfbf1087 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/HTMLMetadata.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/HTMLMetadata.swift @@ -2,7 +2,7 @@ import Foundation -public struct HTMLMetadata: Equatable { +public struct HTMLMetadata: Codable, Equatable { /// Parsed from var titleTag: String? /// Parsed from <link rel="icon"...> diff --git a/SessionMessagingKit/Types/LinkPreviewManager.swift b/SessionMessagingKit/Types/LinkPreviewManager.swift new file mode 100644 index 0000000000..a554d01eb1 --- /dev/null +++ b/SessionMessagingKit/Types/LinkPreviewManager.swift @@ -0,0 +1,351 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let linkPreviewManager: SingletonConfig<LinkPreviewManagerType> = Dependencies.create( + identifier: "linkPreviewManager", + createInstance: { dependencies in LinkPreviewManager(using: dependencies) } + ) +} + +// MARK: - Log.Category + +public extension Log.Category { + static let linkPreview: Log.Category = .create("LinkPreview", defaultLevel: .info) +} + +// MARK: - LinkPreviewManager + +public actor LinkPreviewManager: LinkPreviewManagerType { + /// Twitter doesn't return OpenGraph tags to Signal + /// `curl -A Signal "https://twitter.com/signalapp/status/1280166087577997312?s=20"` + /// If this ever changes, we can switch back to our default User-Agent + private static let userAgentString: String = "WhatsApp" + + private nonisolated let dependencies: Dependencies + private let urlMatchCache: StringCache = StringCache( + totalCostLimit: 5 * 1024 * 1024 /// Max 5MB of url match data + ) + private let metadataCache: StringCache = StringCache( + totalCostLimit: 5 * 1024 * 1024 /// Max 5MB of url metadata + ) + + public var areLinkPreviewsEnabled: Bool { + get async { + dependencies.mutate(cache: .libSession) { cache in + cache.get(.areLinkPreviewsEnabled) + } + } + } + public var hasSeenLinkPreviewSuggestion: Bool { + get async { dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] } + } + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - Functions + + public func setHasSeenLinkPreviewSuggestion(_ value: Bool) async { + dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] = value + } + + public func allPreviewUrls(forMessageBodyText body: String) async -> [String] { + return allPreviewUrlMatches(forMessageBodyText: body).map { $0.urlString } + } + + public func previewUrl(for text: String?, selectedRange: NSRange?) async -> String? { + guard let text: String = text, await areLinkPreviewsEnabled else { return nil } + + if let cachedUrl: String = urlMatchCache.object(forKey: text) { + guard cachedUrl.count > 0 else { + return nil + } + + return cachedUrl + } + + let previewUrlMatches: [URLMatchResult] = allPreviewUrlMatches(forMessageBodyText: text) + + guard let urlMatch: URLMatchResult = previewUrlMatches.first else { + /// Use empty string to indicate "no preview URL" in the cache. + urlMatchCache.setObject("", forKey: text) + return nil + } + + if let selectedRange: NSRange = selectedRange { + let cursorAtEndOfMatch: Bool = ( + (urlMatch.matchRange.location + urlMatch.matchRange.length) == selectedRange.location + ) + + if selectedRange.location != text.count, (urlMatch.matchRange.intersection(selectedRange) != nil || cursorAtEndOfMatch) { + // we don't want to cache the result here, as we want to fetch the link preview + // if the user moves the cursor. + return nil + } + } + + urlMatchCache.setObject(urlMatch.urlString, forKey: text) + return urlMatch.urlString + } + + public func tryToBuildPreviewInfo( + previewUrl: String, + skipImageDownload: Bool + ) async throws -> LinkPreviewViewModel { + guard await areLinkPreviewsEnabled else { throw LinkPreviewError.featureDisabled } + + /// Force the url to lowercase to ensure we casing doesn't result in redownloading the details + let targetUrl: String = previewUrl.lowercased() + let metadata: HTMLMetadata + + /// Check if we have an in-memory cache of the metadata (no point downloading it again if so) + if + let cachedMetadataJson: String = metadataCache.object(forKey: previewUrl), + let cachedMetadataJsonData: Data = cachedMetadataJson.data(using: .utf8), + let cachedMetadata: HTMLMetadata = try? JSONDecoder(using: dependencies) + .decode(HTMLMetadata.self, from: cachedMetadataJsonData) + { + metadata = cachedMetadata + } + else { + let (data, response) = try await downloadLink(url: previewUrl) + try Task.checkCancellation() /// No use trying to parse and potentially download an image if the task was cancelled + + guard let rawHTML: String = String(bytes: data, encoding: response.stringEncoding ?? .utf8) else { + Log.verbose(.linkPreview, "Could not parse link text") + throw LinkPreviewError.invalidInput + } + + metadata = HTMLMetadata.construct(parsing: rawHTML) + } + + /// Parse the `metadata` and construct a draft + let title: String? = { + let rawTitle: String? = (metadata.ogTitle ?? metadata.titleTag) + + guard + let decodedTitle: String = decodeHTMLEntities(inString: rawTitle ?? ""), + let normalizedTitle: String = LinkPreviewManager.normalizeTitle(title: decodedTitle), + normalizedTitle.count > 0 + else { return nil } + + return normalizedTitle + }() + let imageUrlString: String? = { + guard + let rawImageUrlString: String = (metadata.ogImageUrlString ?? metadata.faviconUrlString), + let imageUrlString: String = decodeHTMLEntities(inString: rawImageUrlString)?.stripped, + imageUrlString.count > 0 + else { return nil } + + return imageUrlString + }() + + Log.verbose(.linkPreview, "Title: \(String(describing: title)), URL: \(String(describing: imageUrlString))") + + let viewModel: LinkPreviewViewModel + + /// If we don't want to download the image, or the imageUrl isn't valid then just return the non-image content + if !skipImageDownload, let imageUrl: URL = imageUrlString.map({ URL(string: $0) }) { + do { + // FIXME: Would be nice to check if we already have this image downloaded (and to use that one instead) + let imageSource: ImageDataManager.DataSource = try await downloadImage(url: imageUrl) + viewModel = LinkPreviewViewModel( + state: .draft, + urlString: previewUrl, + title: title, + imageSource: imageSource + ) + } + catch { + viewModel = LinkPreviewViewModel( + state: .draft, + urlString: previewUrl, + title: title + ) + } + } + else { + viewModel = LinkPreviewViewModel( + state: .draft, + urlString: previewUrl, + title: title + ) + } + + guard viewModel.isValid else { throw LinkPreviewError.noPreview } + + /// Cache the metadata + if + let metadataJson: Data = try? JSONEncoder(using: dependencies).encode(metadata), + let metadataJsonString: String = String(data: metadataJson, encoding: .utf8) + { + metadataCache.setObject(metadataJsonString, forKey: previewUrl) + } + + return viewModel + } + + // MARK: - Private Methods + + private struct URLMatchResult { + let urlString: String + let matchRange: NSRange + } + + private func allPreviewUrlMatches(forMessageBodyText body: String) -> [URLMatchResult] { + let detector: NSDataDetector + do { + detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + } + catch { + return [] + } + + var urlMatches: [URLMatchResult] = [] + let matches = detector.matches(in: body, options: [], range: NSRange(location: 0, length: body.count)) + for match in matches { + guard let matchURL = match.url else { continue } + + // If the URL entered didn't have a scheme it will default to 'http', we want to catch this and + // set the scheme to 'https' instead as we don't load previews for 'http' so this will result + // in more previews actually getting loaded without forcing the user to enter 'https://' before + // every URL they enter + let urlString: String = (matchURL.absoluteString == "http://\(body)" ? + "https://\(body)" : + matchURL.absoluteString + ) + + if LinkPreviewManager.isValidLinkUrl(urlString) { + let matchResult = URLMatchResult(urlString: urlString, matchRange: match.range) + urlMatches.append(matchResult) + } + } + + return urlMatches + } + + private func downloadLink( + url urlString: String, + remainingRetries: UInt = 3 + ) async throws -> (Data, URLResponse) { + Log.verbose(.linkPreview, "Download url: \(urlString)") + + /// Don't use any caching to protect privacy of these requests + let sessionConfiguration: URLSessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.urlCache = nil + + guard + var request: URLRequest = URL(string: urlString).map({ URLRequest(url: $0) }), + request.url?.scheme != nil, + (request.url?.host ?? "").isEmpty == false, + ContentProxy.configureProxiedRequest(request: &request) + else { throw LinkPreviewError.assertionFailure } + + request.setValue(LinkPreviewManager.userAgentString, forHTTPHeaderField: "User-Agent") /// Set a fake value + + let session: URLSession = URLSession(configuration: sessionConfiguration) + + do { + let (data, response) = try await session.data(for: request) + + guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { + throw LinkPreviewError.assertionFailure + } + + if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { + guard contentType.lowercased().hasPrefix("text/") else { + throw LinkPreviewError.invalidContent + } + } + + guard data.count > 0 else { throw LinkPreviewError.invalidContent } + + return (data, response) + } + catch { + /// Network failures are retried. + guard (error as NSError).domain == kCFErrorDomainCFNetwork as String, remainingRetries > 0 else { + throw LinkPreviewError.couldNotDownload + } + + return try await downloadLink( + url: urlString, + remainingRetries: (remainingRetries - 1) + ) + } + } + + private func downloadImage(url: URL) async throws -> ImageDataManager.DataSource { + guard let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( + url: url as NSURL + ) else { throw LinkPreviewError.invalidInput } + + do { + let asset: ProxiedContentAsset = try await dependencies[singleton: .proxiedContentDownloader] + .requestAsset( + assetDescription: assetDescription, + priority: .high, + shouldIgnoreSignalProxy: true + ) + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(.url(URL(fileURLWithPath: asset.filePath))), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .webPLossy(maxDimension: 1024))], + using: dependencies + ) + + return .url(URL(fileURLWithPath: preparedAttachment.filePath)) + } + catch { throw LinkPreviewError.couldNotDownload } + } + + private func decodeHTMLEntities(inString value: String) -> String? { + guard let data = value.data(using: .utf8) else { return nil } + + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + guard let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) else { + return nil + } + + return attributedString.string + } + + public static func normalizeTitle(title: String?) -> String? { + guard var result: String = title, !result.isEmpty else { return nil } + + /// Truncate title after 2 lines of text + let maxLineCount: Int = 2 + var components: [String] = result.components(separatedBy: .newlines) + + if components.count > maxLineCount { + components = Array(components[0..<maxLineCount]) + result = components.joined(separator: "\n") + } + + let maxCharacterCount: Int = 2048 + if result.count > maxCharacterCount { + let endIndex = result.index(result.startIndex, offsetBy: maxCharacterCount) + result = String(result[..<endIndex]) + } + + return result.filteredForDisplay + } +} diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 65b3797a6d..7001f993cc 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -268,8 +268,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView "\(attachmentText ?? "")\n\n\(messageText ?? "")" // stringlint:ignore ) }() - let linkPreviewDraft: LinkPreviewDraft? = (isSharingUrl ? - viewModel.linkPreviewDrafts.first(where: { $0.urlString == body }) : + let linkPreviewViewModel: LinkPreviewViewModel? = (isSharingUrl ? + viewModel.linkPreviewViewModels.first(where: { $0.urlString == body }) : nil ) let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId @@ -309,13 +309,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView }() try Task.checkCancellation() - /// If there is a `LinkPreviewDraft` then we may need to add it, so generate it's attachment if possible + /// If there is a `LinkPreviewViewModel` then we may need to add it, so generate it's attachment if possible var linkPreviewPreparedAttachment: PreparedAttachment? - if let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft { + if let linkPreviewViewModel: LinkPreviewViewModel = linkPreviewViewModel { linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( - urlString: linkPreviewDraft.urlString, - imageSource: linkPreviewDraft.imageSource, + urlString: linkPreviewViewModel.urlString, + imageSource: linkPreviewViewModel.imageSource, using: dependencies ) } @@ -368,7 +368,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs( sentTimestampMs: Double(sentTimestampMs) ), - linkPreviewUrl: linkPreviewDraft?.urlString, + linkPreviewUrl: linkPreviewViewModel?.urlString, using: dependencies ).inserted(db) sharedInteractionId = interaction.id @@ -381,12 +381,12 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // one then add it now if isSharingUrl, - let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, + let linkPreviewViewModel: LinkPreviewViewModel = linkPreviewViewModel, (try? interaction.linkPreview.isEmpty(db)) == true { try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, + url: linkPreviewViewModel.urlString, + title: linkPreviewViewModel.title, attachmentId: linkPreviewPreparedAttachment? .attachment .inserted(db) diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index b29a2c6bc2..6680db1a30 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -16,7 +16,7 @@ public class ThreadPickerViewModel { public let userMetadata: ExtensionHelper.UserMetadata? public let hasNonTextAttachment: Bool // FIXME: Clean up to follow proper MVVM - @MainActor public private(set) var linkPreviewDrafts: [LinkPreviewDraft] = [] + @MainActor public private(set) var linkPreviewViewModels: [LinkPreviewViewModel] = [] init( userMetadata: ExtensionHelper.UserMetadata?, @@ -99,8 +99,8 @@ public class ThreadPickerViewModel { // MARK: - Functions - @MainActor public func didLoadLinkPreview(linkPreview: LinkPreviewDraft) { - linkPreviewDrafts.append(linkPreview) + @MainActor public func didLoadLinkPreview(linkPreview: LinkPreviewViewModel) { + linkPreviewViewModels.append(linkPreview) } public func updateData(_ updatedData: [SessionThreadViewModel]) { diff --git a/SessionUIKit/Components/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift index 55a9d7568f..38b0fd8859 100644 --- a/SessionUIKit/Components/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -21,7 +21,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele // MARK: - Initialization - init( + public init( allowedInputTypes: InputTypes, message: String? = nil, accessibility: Accessibility? = nil, @@ -40,13 +40,14 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private static let thresholdForCharacterLimit: Int = 200 private var disposables: Set<AnyCancellable> = Set() - private let dataManager: ImageDataManagerType + private let imageDataManager: ImageDataManagerType + private let linkPreviewManager: LinkPreviewManagerType private let displayNameRetriever: (String, Bool) -> String? private weak var delegate: InputViewDelegate? private var sessionProState: SessionProManagerType? - public var quoteDraft: QuoteViewModel? { didSet { handleQuoteDraftChanged() } } - public var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + public var quoteViewModel: QuoteViewModel? { didSet { handleQuoteDraftChanged() } } + public var linkPreviewViewModel: LinkPreviewViewModel? private var linkPreviewLoadTask: Task<Void, Never>? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) @@ -64,7 +65,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele }() private lazy var linkPreviewView: LinkPreviewView = LinkPreviewView { [weak self] in - self?.linkPreviewInfo = nil + self?.linkPreviewViewModel = nil self?.linkPreviewContainerView.isHidden = true } @@ -161,7 +162,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele }() private lazy var mentionsView: MentionSelectionView = { - let result: MentionSelectionView = MentionSelectionView(dataManager: dataManager) + let result: MentionSelectionView = MentionSelectionView(dataManager: imageDataManager) result.delegate = self return result @@ -230,9 +231,9 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele quotedAttachmentInfo: nil, displayNameRetriever: displayNameRetriever ), - dataManager: dataManager, + dataManager: imageDataManager, onCancel: { [weak self] in - self?.quoteDraft = nil + self?.quoteViewModel = nil self?.quoteViewContainerView.isHidden = true } ) @@ -335,10 +336,12 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele public init( delegate: InputViewDelegate, displayNameRetriever: @escaping (String, Bool) -> String?, - dataManager: ImageDataManagerType, + imageDataManager: ImageDataManagerType, + linkPreviewManager: LinkPreviewManagerType, sessionProState: SessionProManagerType? ) { - self.dataManager = dataManager + self.imageDataManager = imageDataManager + self.linkPreviewManager = linkPreviewManager self.delegate = delegate self.displayNameRetriever = displayNameRetriever self.sessionProState = sessionProState @@ -437,15 +440,15 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele // URL before removing the quote draft. private func handleQuoteDraftChanged() { - linkPreviewInfo = nil + linkPreviewViewModel = nil linkPreviewContainerView.isHidden = true - guard let quoteDraft: QuoteViewModel = quoteDraft else { + guard let quoteViewModel: QuoteViewModel = quoteViewModel else { quoteViewContainerView.isHidden = true return } - quoteView.update(viewModel: quoteDraft) + quoteView.update(viewModel: quoteViewModel) quoteViewContainerView.isHidden = false } @@ -457,110 +460,113 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele // told them about link previews yet let text = inputTextView.text! - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - let areLinkPreviewsEnabled: Bool = dependencies.mutate(cache: .libSession) { cache in - cache.get(.areLinkPreviewsEnabled) - } + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + let areLinkPreviewsEnabled: Bool = await linkPreviewManager.areLinkPreviewsEnabled + let hasSeenLinkPreviewSuggestion: Bool = await linkPreviewManager.hasSeenLinkPreviewSuggestion if - !LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && + await !linkPreviewManager.allPreviewUrls(forMessageBodyText: text).isEmpty && !areLinkPreviewsEnabled && - !dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] + !hasSeenLinkPreviewSuggestion { - DispatchQueue.main.async { + await MainActor.run { [weak self] in self?.delegate?.showLinkPreviewSuggestionModal() } - dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] = true + await linkPreviewManager.setHasSeenLinkPreviewSuggestion(true) return } // Check that link previews are enabled guard areLinkPreviewsEnabled else { return } // Proceed - DispatchQueue.main.async { - self?.autoGenerateLinkPreview() - } + await autoGenerateLinkPreview() } } - @MainActor func autoGenerateLinkPreview() { + func autoGenerateLinkPreview() async { // Check that a valid URL is present - guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange, using: dependencies) else { - return - } - - // Guard against obsolete updates - guard linkPreviewURL != self.linkPreviewInfo?.url else { return } - - // Clear content container - quoteDraft = nil - quoteViewContainerView.isHidden = true - - // Set the state to loading (but don't show yet) - linkPreviewInfo = (url: linkPreviewURL, draft: nil) - linkPreviewView.update( - with: LinkPreview.LoadingState(), - maxWidth: (mainStackView.bounds.width - InputView.linkPreviewViewInset), - isOutgoing: false, - using: dependencies - ) + guard + let linkPreviewUrl: String = await linkPreviewManager.previewUrl( + for: text, + selectedRange: inputTextView.selectedRange + ), + linkPreviewUrl != self.linkPreviewViewModel?.urlString /// Guard against obsolete updates + else { return } - // Build the link preview - linkPreviewLoadTask?.cancel() - linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes, dependencies] in - await withThrowingTaskGroup(of: Void.self) { [weak self] group in - /// Wait for a short period before showing the link preview UI (this is to avoid a situation where an invalid URL shows - /// the loading state very briefly before it disappears - group.addTask { [weak self] in - try await Task.sleep(for: .milliseconds(50)) - - await MainActor.run { [weak self] in - guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete - - linkPreviewContainerView.isHidden = false - } - } - group.addTask { [weak self] in - do { - /// Load the draft - let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( - previewUrl: linkPreviewURL, - skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled - using: dependencies - ) - try Task.checkCancellation() + await MainActor.run { [weak self] in + guard let self else { return } + + /// Clear content container + quoteViewModel = nil + quoteViewContainerView.isHidden = true + + // Set the state to loading (but don't show yet) + linkPreviewViewModel = LinkPreviewViewModel(state: .loading, urlString: linkPreviewUrl) + linkPreviewView.update( + with: LinkPreviewViewModel(state: .loading, urlString: linkPreviewUrl), + isOutgoing: false, + dataManager: imageDataManager + ) + + /// Build the link preview + linkPreviewLoadTask?.cancel() + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes] in + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + /// Wait for a short period before showing the link preview UI (this is to avoid a situation where an invalid URL shows + /// the loading state very briefly before it disappears + group.addTask { [weak self] in + try await Task.sleep(for: .milliseconds(50)) await MainActor.run { [weak self] in guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + guard linkPreviewViewModel?.urlString == linkPreviewUrl else { return } /// Obsolete - linkPreviewInfo = (url: linkPreviewURL, draft: draft) - linkPreviewView.update( - with: LinkPreview.DraftState(linkPreviewDraft: draft), - maxWidth: (mainStackView.bounds.width - InputView.linkPreviewViewInset), - isOutgoing: false, - using: dependencies - ) linkPreviewContainerView.isHidden = false - setNeedsLayout() - layoutIfNeeded() } } - catch { - await MainActor.run { [weak self] in - guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + group.addTask { [weak self] in + guard let self else { return } + + do { + /// Load the draft + let viewModel: LinkPreviewViewModel = try await linkPreviewManager.tryToBuildPreviewInfo( + previewUrl: linkPreviewUrl, + skipImageDownload: (allowedInputTypes != .all) /// Disable if attachments are disabled + ) + try Task.checkCancellation() - linkPreviewInfo = nil - linkPreviewContainerView.isHidden = true - setNeedsLayout() - layoutIfNeeded() + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewViewModel?.urlString == linkPreviewUrl else { return } /// Obsolete + + linkPreviewViewModel = viewModel + linkPreviewView.update( + with: viewModel, + isOutgoing: false, + dataManager: imageDataManager + ) + linkPreviewContainerView.isHidden = false + setNeedsLayout() + layoutIfNeeded() + } + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewViewModel?.urlString == linkPreviewUrl else { return } /// Obsolete + + linkPreviewViewModel = nil + linkPreviewContainerView.isHidden = true + setNeedsLayout() + layoutIfNeeded() + } } } + + try? await group.waitForAll() } - - try? await group.waitForAll() } } } diff --git a/SessionUIKit/Components/LinkPreviewView.swift b/SessionUIKit/Components/LinkPreviewView.swift index 57bf0f56d7..679b3d5e03 100644 --- a/SessionUIKit/Components/LinkPreviewView.swift +++ b/SessionUIKit/Components/LinkPreviewView.swift @@ -17,6 +17,13 @@ public struct LinkPreviewViewModel { public var title: String? public var imageSource: ImageDataManager.DataSource? + public var isValid: Bool { + let hasTitle = (title == nil || title?.isEmpty == false) + let hasImage: Bool = (imageSource != nil) + + return (hasTitle || hasImage) + } + public init( state: State, urlString: String, diff --git a/SessionUIKit/Types/LinkPreviewDraft.swift b/SessionUIKit/Types/LinkPreviewDraft.swift deleted file mode 100644 index 5a5de0efd4..0000000000 --- a/SessionUIKit/Types/LinkPreviewDraft.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - TODO: Remove this and just use `LinkPreviewViewModel` -public struct LinkPreviewDraft: Equatable, Hashable { - public var urlString: String - public var title: String? - public var imageSource: ImageDataManager.DataSource? - - public init(urlString: String, title: String?, imageSource: ImageDataManager.DataSource? = nil) { - self.urlString = urlString - self.title = title - self.imageSource = imageSource - } - - public func isValid() -> Bool { - let hasTitle = (title == nil || title?.isEmpty == false) - let hasImage: Bool = (imageSource != nil) - - return (hasTitle || hasImage) - } -} diff --git a/SessionUIKit/Types/LinkPreviewManagerType.swift b/SessionUIKit/Types/LinkPreviewManagerType.swift new file mode 100644 index 0000000000..c6ae46971e --- /dev/null +++ b/SessionUIKit/Types/LinkPreviewManagerType.swift @@ -0,0 +1,25 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - LinkPreviewManagerType + +public protocol LinkPreviewManagerType { + var areLinkPreviewsEnabled: Bool { get async } + var hasSeenLinkPreviewSuggestion: Bool { get async } + + func setHasSeenLinkPreviewSuggestion(_ value: Bool) async + func allPreviewUrls(forMessageBodyText body: String) async -> [String] + func previewUrl(for text: String?, selectedRange: NSRange?) async -> String? + func tryToBuildPreviewInfo(previewUrl: String, skipImageDownload: Bool) async throws -> LinkPreviewViewModel +} + +public extension LinkPreviewManagerType { + nonisolated static func isValidLinkUrl(_ urlString: String) -> Bool { + return (URL(string: urlString) != nil) + } + + func previewUrl(for text: String?) async -> String? { + return await previewUrl(for: text, selectedRange: nil) + } +} diff --git a/SessionUIKit/Utilities/Task+Utilities.swift b/SessionUIKit/Utilities/Task+Utilities.swift new file mode 100644 index 0000000000..0246a834f3 --- /dev/null +++ b/SessionUIKit/Utilities/Task+Utilities.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +internal extension Task where Success == Never, Failure == Never { + /// Suspends the current task until the given deadline (compatibility version). + @available(iOS, introduced: 13.0, obsoleted: 16.0, message: "Use built-in Task.sleep(for:) accepting Swift.Duration on iOS 16+") + static func sleep(for interval: DispatchTimeInterval) async throws { + let nanosecondsToSleep: UInt64 = (UInt64(interval.milliseconds) * 1_000_000) + try await Task.sleep(nanoseconds: nanosecondsToSleep) + } +} + +private extension DispatchTimeInterval { + var milliseconds: Int { + switch self { + case .seconds(let s): return s * 1_000 + case .milliseconds(let ms): return ms + case .microseconds(let us): return us / 1_000 // integer division truncates any remainder + case .nanoseconds(let ns): return ns / 1_000_000 + case .never: return -1 + @unknown default: return -1 + } + } +} + diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 2f843bf0cb..178bda125d 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -76,7 +76,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC var isKeyboardVisible: Bool = false private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? + private let didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)? public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? @@ -145,7 +145,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC attachments: [PendingAttachment], quoteDraft: QuoteViewModel?, disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, + didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)?, using dependencies: Dependencies ) { guard !attachments.isEmpty else { return nil } @@ -193,7 +193,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC quoteDraft: QuoteViewModel?, approvalDelegate: AttachmentApprovalViewControllerDelegate, disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, + didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)?, using dependencies: Dependencies ) -> UINavigationController? { guard let vc = AttachmentApprovalViewController( @@ -246,7 +246,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC threadVariant: threadVariant, using: dependencies ), - using: dependencies + imageDataManager: dependencies[singleton: .imageDataManager], + linkPreviewManager: dependencies[singleton: .linkPreviewManager], + sessionProState: dependencies[singleton: .sessionProState] ) lazy var inputBackgroundView: UIView = { @@ -300,16 +302,23 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.currentPageViewController?.view.layoutIfNeeded() } - // If the first item is just text, or is a URL and LinkPreviews are disabled - // then just fill the 'message' box with it - let firstItemIsPlainText: Bool = { - switch firstItem.attachment.source { - case .text: return true - default: return false + /// If the first item is just text, or is a URL and LinkPreviews are disabled then just fill the 'message' box with it + Task { + let firstItemIsPlainText: Bool = { + switch firstItem.attachment.source { + case .text: return true + default: return false + } + }() + let hasNoLinkPreview: Bool = (firstItem.attachment.utType.conforms(to: .url) ? + await dependencies[singleton: .linkPreviewManager].previewUrl( + for: firstItem.attachment.toText() + ) == nil : + false + ) + if firstItemIsPlainText || hasNoLinkPreview { + bottomToolView.attachmentTextToolbar.text = firstItem.attachment.toText() } - }() - if firstItemIsPlainText || (firstItem.attachment.utType.conforms(to: .url) && LinkPreview.previewUrl(for: firstItem.attachment.toText(), using: dependencies) == nil) { - bottomToolView.attachmentTextToolbar.text = firstItem.attachment.toText() } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index e18801e753..1cbf13d70e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -30,7 +30,7 @@ public class AttachmentPrepViewController: OWSViewController { let attachmentItem: PendingAttachmentRailItem var attachment: PendingAttachment { return attachmentItem.attachment } private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? + private let didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)? // MARK: - UI @@ -104,7 +104,7 @@ public class AttachmentPrepViewController: OWSViewController { init( attachmentItem: PendingAttachmentRailItem, disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, + didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index ef3a7188b4..de0687c0aa 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -21,8 +21,8 @@ public class MediaMessageView: UIView { public let mode: Mode public let attachment: PendingAttachment private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: (@MainActor (LinkPreviewDraft) -> Void)? - private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + private let didLoadLinkPreview: (@MainActor (LinkPreviewViewModel) -> Void)? + private var linkPreviewViewModel: LinkPreviewViewModel? private var linkPreviewLoadTask: Task<Void, Never>? // MARK: Initializers @@ -38,7 +38,7 @@ public class MediaMessageView: UIView { attachment: PendingAttachment, mode: MediaMessageView.Mode, disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: (@MainActor (LinkPreviewDraft) -> Void)?, + didLoadLinkPreview: (@MainActor (LinkPreviewViewModel) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies @@ -47,13 +47,13 @@ public class MediaMessageView: UIView { self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload self.didLoadLinkPreview = didLoadLinkPreview - // Set the linkPreviewUrl if it's a url + // Set the linkPreviewViewModel if it's a url if attachment.utType.conforms(to: .url), let attachmentText: String = attachment.toText(), let linkPreviewURL: String = LinkPreview.previewUrl(for: attachmentText, using: dependencies) { - self.linkPreviewInfo = (url: linkPreviewURL, draft: nil) + self.linkPreviewViewModel = LinkPreviewViewModel(state: .loading, urlString: linkPreviewURL) } super.init(frame: CGRect.zero) @@ -144,7 +144,7 @@ public class MediaMessageView: UIView { let stackView: UIStackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical - stackView.alignment = (attachment.utType.conforms(to: .url) && linkPreviewInfo?.url != nil ? .leading : .center) + stackView.alignment = (attachment.utType.conforms(to: .url) && linkPreviewViewModel?.state == .loading ? .leading : .center) stackView.distribution = .fill switch mode { @@ -178,7 +178,7 @@ public class MediaMessageView: UIView { // Content if attachment.utType.conforms(to: .url) { // If we have no link preview info at this point then assume link previews are disabled - if let linkPreviewURL: String = linkPreviewInfo?.url { + if let linkPreviewURL: String = linkPreviewViewModel?.urlString { label.font = .boldSystemFont(ofSize: Values.smallFontSize) label.text = linkPreviewURL label.textAlignment = .left @@ -230,7 +230,7 @@ public class MediaMessageView: UIView { // Content if attachment.utType.conforms(to: .url) { // We only load Link Previews for HTTPS urls so append an explanation for not - if let linkPreviewURL: String = linkPreviewInfo?.url { + if let linkPreviewURL: String = linkPreviewViewModel?.urlString { let httpsScheme: String = "https" // stringlint:ignore if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { @@ -304,13 +304,13 @@ public class MediaMessageView: UIView { imageView.alpha = 0 // Not 'isHidden' because we want it to take up space in the UIStackView loadingView.isHidden = false - if let linkPreviewUrl: String = linkPreviewInfo?.url { + if let linkPreviewUrl: String = linkPreviewViewModel?.urlString { // Don't want to change the axis until we have a URL to start loading, otherwise the // error message will be broken stackView.axis = .horizontal loadLinkPreview( - linkPreviewURL: linkPreviewUrl, + linkPreviewUrl: linkPreviewUrl, skipImageDownload: disableLinkPreviewImageDownload, using: dependencies ) @@ -426,7 +426,7 @@ public class MediaMessageView: UIView { // MARK: - Link Loading @MainActor private func loadLinkPreview( - linkPreviewURL: String, + linkPreviewUrl: String, skipImageDownload: Bool, using dependencies: Dependencies ) { @@ -435,25 +435,24 @@ public class MediaMessageView: UIView { linkPreviewLoadTask?.cancel() linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self] in do { - let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( - previewUrl: linkPreviewURL, - skipImageDownload: skipImageDownload, - using: dependencies + let viewModel: LinkPreviewViewModel = try await dependencies[singleton: .linkPreviewManager].tryToBuildPreviewInfo( + previewUrl: linkPreviewUrl, + skipImageDownload: skipImageDownload ) await MainActor.run { [weak self] in guard let self else { return } - didLoadLinkPreview?(draft) - linkPreviewInfo = (url: linkPreviewURL, draft: draft) + didLoadLinkPreview?(viewModel) + linkPreviewViewModel = viewModel // Update the UI - titleLabel.text = (draft.title ?? titleLabel.text) + titleLabel.text = (viewModel.title ?? titleLabel.text) loadingView.alpha = 0 loadingView.stopAnimating() imageView.alpha = 1 - if let imageSource: ImageDataManager.DataSource = draft.imageSource { + if let imageSource: ImageDataManager.DataSource = viewModel.imageSource { imageView.loadImage(imageSource) } } @@ -470,7 +469,7 @@ public class MediaMessageView: UIView { /// Set the error text appropriately let httpsScheme: String = "https" // stringlint:ignore - if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { + if URLComponents(string: linkPreviewUrl)?.scheme?.lowercased() != httpsScheme { // This error case is handled already in the 'subtitleLabel' creation } else { From 1b863fdf3313ea9f2e856e8a71bf719a07242a8b Mon Sep 17 00:00:00 2001 From: Morgan Pretty <morgan.t.pretty@gmail.com> Date: Mon, 3 Nov 2025 12:35:48 +1100 Subject: [PATCH 3/3] Finished off changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Linked up quote clearing • Removed the duplicate link preview loading and UI from the MediaMessageView (just use the input UI now) • Cleaned up how the InputView input types is handled a bit • Fixed an issue where link previews weren't rendering in the share extension --- Session.xcodeproj/project.pbxproj | 12 +- .../ConversationVC+Interaction.swift | 120 ++++--- Session/Conversations/ConversationVC.swift | 10 +- .../Content Views/LinkPreviewState.swift | 89 ----- .../SendMediaNavigationController.swift | 62 ++-- Session/Meta/Session+SNUIKit.swift | 8 + .../Utilities/LinkPreview+Convenience.swift | 31 ++ .../Link Previews/LinkPreviewError.swift | 1 + .../SessionThreadViewModel.swift | 14 +- .../Types/LinkPreviewManager.swift | 18 +- .../ShareNavController.swift | 7 + SessionShareExtension/ThreadPickerVC.swift | 40 ++- .../ThreadPickerViewModel.swift | 7 +- .../Components/Input View/InputView.swift | 168 ++++++---- SessionUIKit/Components/LinkPreviewView.swift | 6 + SessionUIKit/Configuration.swift | 9 + .../Types/LinkPreviewManagerType.swift | 1 + ...AttachmentApprovalInputAccessoryView.swift | 120 ------- .../AttachmentApprovalViewController.swift | 314 +++++++++--------- .../AttachmentPrepViewController.swift | 19 +- .../Image Editing/ImageEditorView.swift | 6 - .../MediaMessageView.swift | 202 +++-------- 22 files changed, 537 insertions(+), 727 deletions(-) delete mode 100644 Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift create mode 100644 Session/Utilities/LinkPreview+Convenience.swift delete mode 100644 SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index fb0fbdf2e0..fb2f93648c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -267,7 +267,7 @@ B8D64FBE25BA78310029CFC0 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; B8D64FC725BA78520029CFC0 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; B8D64FCB25BA78A90029CFC0 /* SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */; }; - B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D84EA225DF745A005A043E /* LinkPreviewState.swift */; }; + B8D84EA325DF745A005A043E /* LinkPreview+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D84EA225DF745A005A043E /* LinkPreview+Convenience.swift */; }; B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */; }; B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */; }; B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */; }; @@ -325,7 +325,6 @@ C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */; }; C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF358255B6DCC007E1867 /* MediaMessageView.swift */; }; C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */; }; - C38EF386255B6DD2007E1867 /* AttachmentApprovalInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37D255B6DCF007E1867 /* AttachmentApprovalInputAccessoryView.swift */; }; C38EF387255B6DD2007E1867 /* AttachmentItemCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */; }; C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37F255B6DD0007E1867 /* AttachmentApprovalViewController.swift */; }; C38EF389255B6DD2007E1867 /* AttachmentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF380255B6DD0007E1867 /* AttachmentTextView.swift */; }; @@ -1680,7 +1679,7 @@ B8D07404265C683300F77E07 /* ElegantIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = ElegantIcons.ttf; sourceTree = "<group>"; }; B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Notification+MessageReceiver.swift"; path = "SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift"; sourceTree = SOURCE_ROOT; }; B8D0A26825E4A2C200C1835E /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = "<group>"; }; - B8D84EA225DF745A005A043E /* LinkPreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewState.swift; sourceTree = "<group>"; }; + B8D84EA225DF745A005A043E /* LinkPreview+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinkPreview+Convenience.swift"; sourceTree = "<group>"; }; B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessage.swift; sourceTree = "<group>"; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = "<group>"; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = "<group>"; }; @@ -1740,7 +1739,6 @@ C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ModalActivityIndicatorViewController.swift; path = "SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF358255B6DCC007E1867 /* MediaMessageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MediaMessageView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift"; sourceTree = SOURCE_ROOT; }; C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentTextToolbar.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift"; sourceTree = SOURCE_ROOT; }; - C38EF37D255B6DCF007E1867 /* AttachmentApprovalInputAccessoryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentApprovalInputAccessoryView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift"; sourceTree = SOURCE_ROOT; }; C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentItemCollection.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift"; sourceTree = SOURCE_ROOT; }; C38EF37F255B6DD0007E1867 /* AttachmentApprovalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentApprovalViewController.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift"; sourceTree = SOURCE_ROOT; }; C38EF380255B6DD0007E1867 /* AttachmentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AttachmentTextView.swift; path = "SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextView.swift"; sourceTree = SOURCE_ROOT; }; @@ -2771,6 +2769,7 @@ C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, + B8D84EA225DF745A005A043E /* LinkPreview+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, @@ -2989,7 +2988,6 @@ 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, C328250E25CA06020062D0A7 /* VoiceMessageView.swift */, B8569AE225CBB19A00DBA3DB /* DocumentView.swift */, - B8D84EA225DF745A005A043E /* LinkPreviewState.swift */, B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */, 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */, 7B7037422834B81F000DCF35 /* ReactionContainerView.swift */, @@ -3654,7 +3652,6 @@ C379DCEA2567334F0002D4EB /* Attachment Approval */ = { isa = PBXGroup; children = ( - C38EF37D255B6DCF007E1867 /* AttachmentApprovalInputAccessoryView.swift */, C38EF37F255B6DD0007E1867 /* AttachmentApprovalViewController.swift */, C38EF37E255B6DD0007E1867 /* AttachmentItemCollection.swift */, C38EF382255B6DD1007E1867 /* AttachmentPrepViewController.swift */, @@ -6474,7 +6471,6 @@ C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, FDB3487E2BE856C800B716C2 /* UIBezierPath+Utilities.swift in Sources */, C38EF3B8255B6DE7007E1867 /* ImageEditorTextViewController.swift in Sources */, - C38EF386255B6DD2007E1867 /* AttachmentApprovalInputAccessoryView.swift in Sources */, B8C2B2C82563685C00551B4D /* CircleView.swift in Sources */, C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */, C38EF3BE255B6DE7007E1867 /* OrderedDictionary.swift in Sources */, @@ -7056,7 +7052,7 @@ 7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */, 7B46AAAF28766DF4001AF2DC /* AllMediaViewController.swift in Sources */, FD71162228D983ED00B47552 /* QRCodeScanningViewController.swift in Sources */, - B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */, + B8D84EA325DF745A005A043E /* LinkPreview+Convenience.swift in Sources */, 45C0DC1E1E69011F00E04C47 /* UIStoryboard+OWS.swift in Sources */, FDE754B02C9B96B4002A2623 /* WebRTCSession.swift in Sources */, B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 1d5475cbbd..9e4ceffe80 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -259,9 +259,14 @@ extension ConversationVC: didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, - messageText: String? + messageText: String?, + quoteViewModel: QuoteViewModel? ) { - sendMessage(text: (messageText ?? ""), attachments: attachments) + sendMessage( + text: (messageText ?? ""), + attachments: attachments, + quoteViewModel: quoteViewModel + ) resetMentions() dismiss(animated: true) @@ -282,9 +287,10 @@ extension ConversationVC: didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, - messageText: String? + messageText: String?, + quoteViewModel: QuoteViewModel? ) { - sendMessage(text: (messageText ?? ""), attachments: attachments) + sendMessage(text: (messageText ?? ""), attachments: attachments, quoteViewModel: quoteViewModel) resetMentions() dismiss(animated: true) @@ -467,14 +473,17 @@ extension ConversationVC: func handleLibraryButtonTapped() { let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - let quoteDraft: QuoteViewModel? = self.snInputView.quoteDraft + let quoteViewModel: QuoteViewModel? = self.snInputView.quoteViewModel Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] in DispatchQueue.main.async { let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst( threadId: threadId, threadVariant: threadVariant, - quoteDraft: quoteDraft, + quoteViewModel: quoteViewModel, + onQuoteCancelled: { [weak self] in + self?.snInputView.quoteViewModel = nil + }, using: dependencies ) sendMediaNavController.sendMediaNavDelegate = self @@ -496,7 +505,10 @@ extension ConversationVC: let sendMediaNavController = SendMediaNavigationController.showingCameraFirst( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, - quoteDraft: self.snInputView.quoteDraft, + quoteViewModel: self.snInputView.quoteViewModel, + onQuoteCancelled: { [weak self] in + self?.snInputView.quoteViewModel = nil + }, using: self.viewModel.dependencies ) sendMediaNavController.sendMediaNavDelegate = self @@ -512,16 +524,23 @@ extension ConversationVC: } func showAttachmentApprovalDialog(for attachments: [PendingAttachment]) { - guard let navController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController( + mode: .modal, + delegate: self, + threadId: viewModel.threadData.threadId, + threadVariant: viewModel.threadData.threadVariant, attachments: attachments, - quoteDraft: snInputView.quoteDraft, - approvalDelegate: self, - disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + messageText: snInputView.text, + quoteViewModel: snInputView.quoteViewModel, + disableLinkPreviewImageDownload: (viewModel.threadData.threadCanUpload != true), didLoadLinkPreview: nil, - using: self.viewModel.dependencies - ) else { return } + onQuoteCancelled: { [weak self] in + self?.snInputView.quoteViewModel = nil + }, + using: viewModel.dependencies + ) + + let navController = StyledNavigationController(rootViewController: viewController) navController.modalPresentationStyle = .fullScreen present(navController, animated: true, completion: nil) @@ -583,28 +602,41 @@ extension ConversationVC: @MainActor func handleAttachmentButtonTapped() { if attachmentButtonStackView.isHidden { - snInputView.attachmentsButton.accessibilityLabel = "Collapse attachment options" - attachmentButtonStackView.isHidden = false - - UIView.animate(withDuration: 0.25) { - self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = false } - self.attachmentButtonStackView.alpha = 1 - } + expandAttachmentButtons() } else { - snInputView.attachmentsButton.accessibilityLabel = "Add attachment" - UIView.animate( - withDuration: 0.25, - animations: { - self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = true } - self.attachmentButtonStackView.alpha = 0 - }, - completion: { [weak self] _ in - self?.attachmentButtonStackView.isHidden = true - } - ) + collapseAttachmentButtons() + } + } + + @MainActor func expandAttachmentButtons() { + guard attachmentButtonStackView.isHidden else { return } + + snInputView.attachmentsButton.accessibilityLabel = "Collapse attachment options" + attachmentButtonStackView.isHidden = false + + UIView.animate(withDuration: 0.25) { + self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = false } + self.attachmentButtonStackView.alpha = 1 } } + @MainActor func collapseAttachmentButtons() { + guard !attachmentButtonStackView.isHidden else { return } + + snInputView.attachmentsButton.accessibilityLabel = "Add attachment" + + UIView.animate( + withDuration: 0.25, + animations: { + self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = true } + self.attachmentButtonStackView.alpha = 0 + }, + completion: { [weak self] _ in + self?.attachmentButtonStackView.isHidden = true + } + ) + } + @MainActor func handleDisabledAttachmentButtonTapped() { /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order @@ -738,7 +770,7 @@ extension ConversationVC: // Clearing this out immediately to make this appear more snappy snInputView.text = "" - snInputView.quoteDraft = nil + snInputView.quoteViewModel = nil resetMentions() scrollToBottom(isAnimated: false) @@ -911,7 +943,9 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self, dependencies = viewModel.dependencies] _ in dependencies.setAsync(.areLinkPreviewsEnabled, true) { - self?.snInputView.autoGenerateLinkPreview() + Task { + await self?.snInputView.autoGenerateLinkPreview() + } } } ) @@ -957,19 +991,7 @@ extension ConversationVC: using: viewModel.dependencies ) - guard let approvalVC = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - attachments: [ pendingAttachment ], - quoteDraft: self.snInputView.quoteDraft, - approvalDelegate: self, - disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), - didLoadLinkPreview: nil, - using: self.viewModel.dependencies - ) else { return } - approvalVC.modalPresentationStyle = .fullScreen - - self.present(approvalVC, animated: true, completion: nil) + showAttachmentApprovalDialog(for: [pendingAttachment]) } // MARK: --Mentions @@ -2319,7 +2341,7 @@ extension ConversationVC: cellViewModel.linkPreviewAttachment ) - snInputView.quoteDraft = QuoteViewModel( + snInputView.quoteViewModel = QuoteViewModel( mode: .draft, direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 529c189075..a9f85c40c3 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -350,8 +350,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa threadVariant: self.viewModel.initialThreadVariant, using: self.viewModel.dependencies ), - dataManager: self.viewModel.dependencies[singleton: .imageDataManager], - sessionProState: self.viewModel.dependencies[singleton: .sessionProState] + imageDataManager: self.viewModel.dependencies[singleton: .imageDataManager], + linkPreviewManager: self.viewModel.dependencies[singleton: .linkPreviewManager], + sessionProState: self.viewModel.dependencies[singleton: .sessionProState], + didLoadLinkPreview: nil ) lazy var inputBackgroundView: UIView = { @@ -394,6 +396,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var gifButton: UIView = { let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), hasOpaqueBackground: true) { [weak self] in self?.handleGIFButtonTapped() + self?.collapseAttachmentButtons() } button.accessibilityIdentifier = "GIF button" button.isAccessibilityElement = true @@ -406,6 +409,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var documentButton: UIView = { let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), hasOpaqueBackground: true) { [weak self] in self?.handleDocumentButtonTapped() + self?.collapseAttachmentButtons() } button.accessibilityIdentifier = "Documents folder" button.accessibilityLabel = "Files" @@ -419,6 +423,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var libraryButton: UIView = { let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), hasOpaqueBackground: true) { [weak self] in self?.handleLibraryButtonTapped() + self?.collapseAttachmentButtons() } button.accessibilityIdentifier = "Images folder" button.accessibilityLabel = "Photo library" @@ -432,6 +437,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var cameraButton: UIView = { let button: InputViewButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), hasOpaqueBackground: true) { [weak self] in self?.handleCameraButtonTapped() + self?.collapseAttachmentButtons() } button.accessibilityIdentifier = "Select camera button" button.accessibilityLabel = "Camera" diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift deleted file mode 100644 index eb9c5fc4e2..0000000000 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit - -protocol LinkPreviewState { - var isLoaded: Bool { get } - var urlString: String? { get } - var title: String? { get } - var imageState: LinkPreview.ImageState { get } - var imageSource: ImageDataManager.DataSource? { get } -} - -public extension LinkPreview { - enum ImageState: Int { - case none - case loading - case loaded - case invalid - } - - // MARK: LoadingState - - struct LoadingState: LinkPreviewState { - var isLoaded: Bool { false } - var urlString: String? { nil } - var title: String? { nil } - var imageState: LinkPreview.ImageState { .none } - var imageSource: ImageDataManager.DataSource? { nil } - } - - // MARK: DraftState - - struct DraftState: LinkPreviewState { - var isLoaded: Bool { true } - var urlString: String? { linkPreviewDraft.urlString } - - var title: String? { - guard let value = linkPreviewDraft.title, value.count > 0 else { return nil } - - return value - } - - var imageState: LinkPreview.ImageState { - if linkPreviewDraft.imageSource != nil { return .loaded } - - return .none - } - - var imageSource: ImageDataManager.DataSource? { linkPreviewDraft.imageSource } - - // MARK: - Type Specific - - private let linkPreviewDraft: LinkPreviewDraft - - // MARK: - Initialization - - init(linkPreviewDraft: LinkPreviewDraft) { - self.linkPreviewDraft = linkPreviewDraft - } - } - - // MARK: - SentState - - func sentState( - imageAttachment: Attachment?, - using dependencies: Dependencies - ) -> LinkPreviewViewModel { - return LinkPreviewViewModel( - state: .sent, - urlString: url, - title: (title?.isEmpty == false ? title : nil), - imageSource: { - /// **Note:** We don't check if the image is valid here because that can be confirmed in 'imageState' and it's a - /// little inefficient - guard - imageAttachment?.isImage == true, - let imageDownloadUrl: String = imageAttachment?.downloadUrl, - let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: imageDownloadUrl) - else { return nil } - - return .url(URL(fileURLWithPath: path)) - }() - ) - } -} diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 11627b95f3..8f30c31597 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -20,7 +20,8 @@ class SendMediaNavigationController: UINavigationController { private let dependencies: Dependencies private let threadId: String private let threadVariant: SessionThread.Variant - private var quoteDraft: QuoteViewModel? + private var quoteViewModel: QuoteViewModel? + private let onQuoteCancelled: (() -> Void)? private var disposables: Set<AnyCancellable> = Set() private var loadMediaTask: Task<Void, Never>? @@ -29,13 +30,15 @@ class SendMediaNavigationController: UINavigationController { init( threadId: String, threadVariant: SessionThread.Variant, - quoteDraft: QuoteViewModel?, + quoteViewModel: QuoteViewModel?, + onQuoteCancelled: (() -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies self.threadId = threadId self.threadVariant = threadVariant - self.quoteDraft = quoteDraft + self.quoteViewModel = quoteViewModel + self.onQuoteCancelled = onQuoteCancelled super.init(nibName: nil, bundle: nil) } @@ -93,13 +96,15 @@ class SendMediaNavigationController: UINavigationController { public class func showingCameraFirst( threadId: String, threadVariant: SessionThread.Variant, - quoteDraft: QuoteViewModel?, + quoteViewModel: QuoteViewModel?, + onQuoteCancelled: (() -> Void)?, using dependencies: Dependencies ) -> SendMediaNavigationController { let navController: SendMediaNavigationController = SendMediaNavigationController( threadId: threadId, threadVariant: threadVariant, - quoteDraft: quoteDraft, + quoteViewModel: quoteViewModel, + onQuoteCancelled: onQuoteCancelled, using: dependencies ) navController.viewControllers = [navController.captureViewController] @@ -110,13 +115,15 @@ class SendMediaNavigationController: UINavigationController { public class func showingMediaLibraryFirst( threadId: String, threadVariant: SessionThread.Variant, - quoteDraft: QuoteViewModel?, + quoteViewModel: QuoteViewModel?, + onQuoteCancelled: (() -> Void)?, using dependencies: Dependencies ) -> SendMediaNavigationController { let navController: SendMediaNavigationController = SendMediaNavigationController( threadId: threadId, threadVariant: threadVariant, - quoteDraft: quoteDraft, + quoteViewModel: quoteViewModel, + onQuoteCancelled: onQuoteCancelled, using: dependencies ) navController.viewControllers = [navController.mediaLibraryViewController] @@ -265,23 +272,24 @@ class SendMediaNavigationController: UINavigationController { return false } - guard - let approvalViewController: AttachmentApprovalViewController = AttachmentApprovalViewController( - mode: .sharedNavigation, - threadId: self.threadId, - threadVariant: self.threadVariant, - attachments: self.attachments, - quoteDraft: self.quoteDraft, - disableLinkPreviewImageDownload: false, - didLoadLinkPreview: nil, - using: dependencies - ) - else { return false } - - approvalViewController.approvalDelegate = self - approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self) + let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController( + mode: .sharedNavigation, + delegate: self, + threadId: self.threadId, + threadVariant: self.threadVariant, + attachments: self.attachments, + messageText: sendMediaNavDelegate.sendMediaNavInitialMessageText(self), + quoteViewModel: self.quoteViewModel, + disableLinkPreviewImageDownload: false, + didLoadLinkPreview: nil, + onQuoteCancelled: { [weak self] in + self?.quoteViewModel = nil + self?.onQuoteCancelled?() + }, + using: dependencies + ) - pushViewController(approvalViewController, animated: true) + pushViewController(viewController, animated: true) return true } @@ -505,14 +513,16 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, - messageText: String? + messageText: String?, + quoteViewModel: QuoteViewModel? ) { sendMediaNavDelegate?.sendMediaNav( self, didApproveAttachments: attachments, forThreadId: threadId, threadVariant: threadVariant, - messageText: messageText + messageText: messageText, + quoteViewModel: quoteViewModel ) } @@ -835,7 +845,7 @@ private class DoneButton: UIView { protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?, quoteViewModel: QuoteViewModel?) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 9eeece9795..aa4c18e0cd 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -5,6 +5,7 @@ import AVFoundation import UniformTypeIdentifiers import SessionUIKit import SessionNetworkingKit +import SessionMessagingKit import SessionUtilitiesKit // MARK: - SessionSNUIKitConfig @@ -118,4 +119,11 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { func mediaDecoderSource(for data: Data) -> CGImageSource? { return dependencies[singleton: .mediaDecoder].source(for: data) } + + @MainActor func numberOfCharactersLeft(for text: String) -> Int { + return LibSession.numberOfCharactersLeft( + for: text, + isSessionPro: dependencies[cache: .libSession].isSessionPro + ) + } } diff --git a/Session/Utilities/LinkPreview+Convenience.swift b/Session/Utilities/LinkPreview+Convenience.swift new file mode 100644 index 0000000000..de8f70266c --- /dev/null +++ b/Session/Utilities/LinkPreview+Convenience.swift @@ -0,0 +1,31 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit + +public extension LinkPreview { + func sentState( + imageAttachment: Attachment?, + using dependencies: Dependencies + ) -> LinkPreviewViewModel { + return LinkPreviewViewModel( + state: .sent, + urlString: url, + title: (title?.isEmpty == false ? title : nil), + imageSource: { + /// **Note:** We don't check if the image is valid here because that can be confirmed in 'imageState' and it's a + /// little inefficient + guard + imageAttachment?.isImage == true, + let imageDownloadUrl: String = imageAttachment?.downloadUrl, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: imageDownloadUrl) + else { return nil } + + return .url(URL(fileURLWithPath: path)) + }() + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift index 145541a8c6..33662358d0 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewError.swift @@ -11,4 +11,5 @@ public enum LinkPreviewError: Int, Error { case invalidContent case invalidMediaContent case attachmentFailedToSave + case insecureLink } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 17d01726f9..419944bed7 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -245,10 +245,10 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D } public var messageInputState: InputView.InputState { - guard !threadIsNoteToSelf else { return InputView.InputState(allowedInputTypes: .all) } + guard !threadIsNoteToSelf else { return InputView.InputState(inputs: .all) } guard threadIsBlocked != true else { return InputView.InputState( - allowedInputTypes: .none, + inputs: .disabled, message: "blockBlockedDescription".localized(), messageAccessibility: Accessibility( identifier: "Blocked banner" @@ -258,21 +258,21 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D if threadVariant == .community && threadCanWrite == false { return InputView.InputState( - allowedInputTypes: .none, + inputs: .disabled, message: "permissionsWriteCommunity".localized() ) } /// Attachments shouldn't be allowed for message requests or if uploads are disabled - let finalInputType: InputView.InputTypes + let finalInputs: InputView.Input switch (threadRequiresApproval, threadIsMessageRequest, threadCanUpload) { - case (false, false, true): finalInputType = .all - default: finalInputType = .textOnly + case (false, false, true): finalInputs = .all + default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled] } return InputView.InputState( - allowedInputTypes: finalInputType + inputs: finalInputs ) } diff --git a/SessionMessagingKit/Types/LinkPreviewManager.swift b/SessionMessagingKit/Types/LinkPreviewManager.swift index a554d01eb1..6d96ce63bb 100644 --- a/SessionMessagingKit/Types/LinkPreviewManager.swift +++ b/SessionMessagingKit/Types/LinkPreviewManager.swift @@ -26,7 +26,7 @@ public actor LinkPreviewManager: LinkPreviewManagerType { /// Twitter doesn't return OpenGraph tags to Signal /// `curl -A Signal "https://twitter.com/signalapp/status/1280166087577997312?s=20"` /// If this ever changes, we can switch back to our default User-Agent - private static let userAgentString: String = "WhatsApp" + private static let userAgentString: String = "WhatsApp" // strinlint:ignore private nonisolated let dependencies: Dependencies private let urlMatchCache: StringCache = StringCache( @@ -98,14 +98,19 @@ public actor LinkPreviewManager: LinkPreviewManagerType { return urlMatch.urlString } + public func ensureLinkPreviewsEnabled() async throws { + if await !areLinkPreviewsEnabled { + throw LinkPreviewError.featureDisabled + } + } + public func tryToBuildPreviewInfo( previewUrl: String, skipImageDownload: Bool ) async throws -> LinkPreviewViewModel { - guard await areLinkPreviewsEnabled else { throw LinkPreviewError.featureDisabled } + try await ensureLinkPreviewsEnabled() /// Force the url to lowercase to ensure we casing doesn't result in redownloading the details - let targetUrl: String = previewUrl.lowercased() let metadata: HTMLMetadata /// Check if we have an in-memory cache of the metadata (no point downloading it again if so) @@ -239,6 +244,13 @@ public actor LinkPreviewManager: LinkPreviewManagerType { url urlString: String, remainingRetries: UInt = 3 ) async throws -> (Data, URLResponse) { + /// We only load Link Previews for HTTPS urls so append an explanation for not + let httpsScheme: String = "https" // stringlint:ignore + + guard URLComponents(string: urlString)?.scheme?.lowercased() == httpsScheme else { + throw LinkPreviewError.insecureLink + } + Log.verbose(.linkPreview, "Download url: \(urlString)") /// Don't use any caching to protect privacy of these requests diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index fc0d3256e7..d7e0762cb6 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -753,4 +753,11 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { func mediaDecoderSource(for data: Data) -> CGImageSource? { return dependencies[singleton: .mediaDecoder].source(for: data) } + + @MainActor func numberOfCharactersLeft(for text: String) -> Int { + return LibSession.numberOfCharactersLeft( + for: text, + isSessionPro: dependencies[cache: .libSession].isSessionPro + ) + } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 7001f993cc..7f9e8bebdc 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -215,27 +215,32 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView guard !attachments.isEmpty, - let self = self, - let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData[indexPath.row].threadId, - threadVariant: self.viewModel.viewData[indexPath.row].threadVariant, - attachments: attachments, - quoteDraft: nil, - approvalDelegate: self, - disableLinkPreviewImageDownload: ( - self.viewModel.viewData[indexPath.row].threadCanUpload != true - ), - didLoadLinkPreview: { [weak self] linkPreview in - self?.viewModel.didLoadLinkPreview(linkPreview: linkPreview) - }, - using: self.viewModel.dependencies - ) + let self = self else { self?.shareNavController?.shareViewFailed(error: AttachmentError.invalidData) return } - navigationController?.present(approvalVC, animated: true, completion: nil) + let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController( + mode: .modal, + delegate: self, + threadId: viewModel.viewData[indexPath.row].threadId, + threadVariant: viewModel.viewData[indexPath.row].threadVariant, + attachments: attachments, + messageText: nil, + quoteViewModel: nil, + disableLinkPreviewImageDownload: (viewModel.viewData[indexPath.row].threadCanUpload != true), + didLoadLinkPreview: { [weak self] result in + self?.viewModel.didLoadLinkPreview(result: result) + }, + onQuoteCancelled: nil, + using: viewModel.dependencies + ) + + let navController = StyledNavigationController(rootViewController: viewController) + navController.modalPresentationStyle = .fullScreen + + navigationController?.present(navController, animated: true, completion: nil) } } @@ -244,7 +249,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, - messageText: String? + messageText: String?, + quoteViewModel: QuoteViewModel? ) { // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 6680db1a30..cf279e19b7 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -99,8 +99,11 @@ public class ThreadPickerViewModel { // MARK: - Functions - @MainActor public func didLoadLinkPreview(linkPreview: LinkPreviewViewModel) { - linkPreviewViewModels.append(linkPreview) + @MainActor public func didLoadLinkPreview(result: LinkPreviewViewModel.LoadResult) { + switch result { + case .success(let linkPreview): linkPreviewViewModels.append(linkPreview) + default: break + } } public func updateData(_ updatedData: [SessionThreadViewModel]) { diff --git a/SessionUIKit/Components/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift index 38b0fd8859..42aed906bd 100644 --- a/SessionUIKit/Components/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -5,30 +5,47 @@ import UniformTypeIdentifiers import Combine public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { - public enum InputTypes: Equatable { - case all - case textOnly - case none + public struct Input: Equatable, OptionSet { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let text: Input = Input(rawValue: 1 << 0) + public static let attachments: Input = Input(rawValue: 1 << 1) + public static let voiceMessages: Input = Input(rawValue: 1 << 2) + public static let attachmentsDisabled: Input = Input(rawValue: 1 << 3) + public static let voiceMessagesDisabled: Input = Input(rawValue: 1 << 4) + + /// Used when we want to allow attachments/uploads but not show the attachments button + public static let attachmentsHidden: Input = Input(rawValue: 1 << 5) + + public static let all: Input = [.text, .attachments, .voiceMessages] + public static let disabled: Input = [.attachmentsDisabled, .voiceMessagesDisabled] } public struct InputState: Equatable { - public let allowedInputTypes: InputTypes + public let inputs: Input public let message: String? + public let alwaysShowSendButton: Bool public let accessibility: Accessibility? public let messageAccessibility: Accessibility? - public static var all: InputState = InputState(allowedInputTypes: .all) + public static var all: InputState = InputState(inputs: .all) // MARK: - Initialization public init( - allowedInputTypes: InputTypes, + inputs: Input, message: String? = nil, + alwaysShowSendButton: Bool = false, accessibility: Accessibility? = nil, messageAccessibility: Accessibility? = nil ) { - self.allowedInputTypes = allowedInputTypes + self.inputs = inputs self.message = message + self.alwaysShowSendButton = alwaysShowSendButton self.accessibility = accessibility self.messageAccessibility = messageAccessibility } @@ -42,7 +59,9 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private var disposables: Set<AnyCancellable> = Set() private let imageDataManager: ImageDataManagerType private let linkPreviewManager: LinkPreviewManagerType + private let didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? private let displayNameRetriever: (String, Bool) -> String? + private let onQuoteCancelled: (() -> Void)? private weak var delegate: InputViewDelegate? private var sessionProState: SessionProManagerType? @@ -92,7 +111,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele // MARK: - UI - private lazy var tapGestureRecognizer: UITapGestureRecognizer = { + private lazy var disabledInputTapGestureRecognizer: UITapGestureRecognizer = { let result: UITapGestureRecognizer = UITapGestureRecognizer() result.addTarget(self, action: #selector(disabledInputTapped)) result.isEnabled = false @@ -130,7 +149,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele public lazy var sendButton: InputViewButton = { let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) - result.isHidden = true + result.isHidden = !inputState.alwaysShowSendButton result.accessibilityIdentifier = "Send message button" result.accessibilityLabel = "Send message button" result.isAccessibilityElement = true @@ -233,8 +252,9 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele ), dataManager: imageDataManager, onCancel: { [weak self] in - self?.quoteViewModel = nil - self?.quoteViewContainerView.isHidden = true + self?.quoteViewModel = nil + self?.quoteViewContainerView.isHidden = true + self?.onQuoteCancelled?() } ) @@ -296,7 +316,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: .small) - // TODO: Need to add this back + // TODO: [PRO] Need to add this back // result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result @@ -338,13 +358,17 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele displayNameRetriever: @escaping (String, Bool) -> String?, imageDataManager: ImageDataManagerType, linkPreviewManager: LinkPreviewManagerType, - sessionProState: SessionProManagerType? + sessionProState: SessionProManagerType?, + onQuoteCancelled: (() -> Void)? = nil, + didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? ) { self.imageDataManager = imageDataManager self.linkPreviewManager = linkPreviewManager self.delegate = delegate self.displayNameRetriever = displayNameRetriever self.sessionProState = sessionProState + self.didLoadLinkPreview = didLoadLinkPreview + self.onQuoteCancelled = onQuoteCancelled super.init(frame: CGRect.zero) @@ -377,7 +401,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight - addGestureRecognizer(tapGestureRecognizer) + addGestureRecognizer(disabledInputTapGestureRecognizer) addGestureRecognizer(swipeGestureRecognizer) // Main stack view @@ -411,20 +435,21 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele @MainActor public func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - sendButton.isHidden = !hasText - voiceMessageButtonContainer.isHidden = hasText + sendButton.isHidden = (!hasText && !inputState.alwaysShowSendButton) + voiceMessageButtonContainer.isHidden = ( + hasText || + inputState.alwaysShowSendButton || ( + !inputState.inputs.contains(.voiceMessages) && + !inputState.inputs.contains(.voiceMessagesDisabled) + ) + ) autoGenerateLinkPreviewIfPossible() delegate?.inputTextViewDidChangeContent(inputTextView) } - @MainActor func updateNumberOfCharactersLeft(_ text: String) { - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), - // TODO: Need to add this back - isSessionPro: false -// isSessionPro: dependencies[cache: .libSession].isSessionPro - ) + @MainActor public func updateNumberOfCharactersLeft(_ text: String) { + let numberOfCharactersLeft: Int = SNUIKit.numberOfCharactersLeft(for: text) characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary proStackView.alpha = (numberOfCharactersLeft <= Self.thresholdForCharacterLimit) ? 1 : 0 @@ -453,12 +478,12 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele } private func autoGenerateLinkPreviewIfPossible() { - // Don't allow link previews on 'none' or 'textOnly' input - guard inputState.allowedInputTypes == .all else { return } + // If attachments aren't enabled then don't allow link previews + guard inputState.inputs.contains(.attachments) else { return } // 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 text: String = (inputTextView.text ?? "") Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } @@ -477,15 +502,17 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele await linkPreviewManager.setHasSeenLinkPreviewSuggestion(true) return } - // Check that link previews are enabled - guard areLinkPreviewsEnabled else { return } // Proceed - await autoGenerateLinkPreview() + do { + try await linkPreviewManager.ensureLinkPreviewsEnabled() + await autoGenerateLinkPreview() + } + catch { await didLoadLinkPreview?(.error(error)) } } } - func autoGenerateLinkPreview() async { + public func autoGenerateLinkPreview() async { // Check that a valid URL is present guard let linkPreviewUrl: String = await linkPreviewManager.previewUrl( @@ -512,7 +539,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele /// Build the link preview linkPreviewLoadTask?.cancel() - linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes] in + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, inputs = inputState.inputs] in await withThrowingTaskGroup(of: Void.self) { [weak self] group in /// Wait for a short period before showing the link preview UI (this is to avoid a situation where an invalid URL shows /// the loading state very briefly before it disappears @@ -521,7 +548,10 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele await MainActor.run { [weak self] in guard let self else { return } - guard linkPreviewViewModel?.urlString == linkPreviewUrl else { return } /// Obsolete + guard linkPreviewViewModel?.urlString == linkPreviewUrl else { + didLoadLinkPreview?(.obsolete) /// Obsolete + return + } linkPreviewContainerView.isHidden = false } @@ -530,18 +560,25 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele guard let self else { return } do { - /// Load the draft + /// Load the draft (If attachments aren't enabled then don't download link preview images) let viewModel: LinkPreviewViewModel = try await linkPreviewManager.tryToBuildPreviewInfo( previewUrl: linkPreviewUrl, - skipImageDownload: (allowedInputTypes != .all) /// Disable if attachments are disabled + skipImageDownload: ( + !inputs.contains(.attachments) && + !inputs.contains(.attachmentsHidden) + ) ) try Task.checkCancellation() await MainActor.run { [weak self] in guard let self else { return } - guard linkPreviewViewModel?.urlString == linkPreviewUrl else { return } /// Obsolete + guard linkPreviewViewModel?.urlString == linkPreviewUrl else { + didLoadLinkPreview?(.obsolete) /// Obsolete + return + } linkPreviewViewModel = viewModel + didLoadLinkPreview?(.success(viewModel)) linkPreviewView.update( with: viewModel, isOutgoing: false, @@ -555,8 +592,12 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele catch { await MainActor.run { [weak self] in guard let self else { return } - guard linkPreviewViewModel?.urlString == linkPreviewUrl else { return } /// Obsolete + guard linkPreviewViewModel?.urlString == linkPreviewUrl else { + didLoadLinkPreview?(.obsolete) /// Obsolete + return + } + didLoadLinkPreview?(.error(error)) linkPreviewViewModel = nil linkPreviewContainerView.isHidden = true setNeedsLayout() @@ -571,54 +612,57 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele } } - @MainActor func setMessageInputState(_ updatedInputState: InputState) { + @MainActor public func setMessageInputState(_ updatedInputState: InputState) { guard inputState != updatedInputState else { return } self.accessibilityIdentifier = updatedInputState.accessibility?.identifier self.accessibilityLabel = updatedInputState.accessibility?.label - tapGestureRecognizer.isEnabled = (updatedInputState.allowedInputTypes == .none) + let hasText: Bool = ((inputTextView.text ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) inputState = updatedInputState + sendButton.isHidden = (!hasText && !inputState.alwaysShowSendButton) disabledInputLabel.text = (updatedInputState.message ?? "") disabledInputLabel.accessibilityIdentifier = updatedInputState.messageAccessibility?.identifier disabledInputLabel.accessibilityLabel = updatedInputState.messageAccessibility?.label - attachmentsButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) - voiceMessageButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) + disabledInputTapGestureRecognizer.isEnabled = (updatedInputState.inputs.isEmpty) + attachmentsButtonContainer.isHidden = !updatedInputState.inputs.contains(.attachments) + voiceMessageButtonContainer.isHidden = !updatedInputState.inputs.contains(.voiceMessages) + attachmentsButton.isSoftDisabled = updatedInputState.inputs.contains(.attachmentsDisabled) + voiceMessageButton.isSoftDisabled = updatedInputState.inputs.contains(.voiceMessagesDisabled) UIView.animate(withDuration: 0.3) { [weak self] in - self?.bottomStackView.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } - - self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) - self?.attachmentsButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) - - self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) - self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.bottomStackView.arrangedSubviews.forEach { $0.alpha = updatedInputState.inputs.isEmpty ? 0 : 1 } + self?.disabledInputLabel.alpha = (updatedInputState.inputs.isEmpty ? Values.mediumOpacity : 0) + self?.attachmentsButton.alpha = (updatedInputState.inputs.contains(.attachmentsDisabled) ? 0.4 : 1) + self?.voiceMessageButton.alpha = (updatedInputState.inputs.contains(.voiceMessagesDisabled) ? 0.4 : 1) - self?.disabledInputLabel.alpha = (updatedInputState.allowedInputTypes != .none ? 0 : Values.mediumOpacity) + self?.attachmentsButton.updateAppearance(isEnabled: updatedInputState.inputs.contains(.attachments)) + self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.inputs.contains(.voiceMessages)) } } // MARK: - Interaction @MainActor public func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { - if inputViewButton == attachmentsButton { - if inputState.allowedInputTypes != .all { - delegate?.handleDisabledAttachmentButtonTapped() - } - else { - delegate?.handleAttachmentButtonTapped() - } + if inputState.inputs.contains(.attachments) && inputViewButton == attachmentsButton { + delegate?.handleAttachmentButtonTapped() } - if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } - if inputViewButton == voiceMessageButton && inputState.allowedInputTypes != .all { + else if inputState.inputs.contains(.attachmentsDisabled) && inputViewButton == attachmentsButton { + delegate?.handleDisabledAttachmentButtonTapped() + } + else if inputState.inputs.contains(.voiceMessagesDisabled) && inputViewButton == voiceMessageButton { delegate?.handleDisabledVoiceMessageButtonTapped() } + else if inputViewButton == sendButton { + delegate?.handleSendButtonTapped() + } } @MainActor public func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { guard inputViewButton == voiceMessageButton else { return } - guard inputState.allowedInputTypes == .all else { return } + guard inputState.inputs.contains(.voiceMessages) else { return } // Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording' // because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to @@ -681,7 +725,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele } } - func hideVoiceMessageUI() { + @MainActor public func hideVoiceMessageUI() { let allOtherViews = [ attachmentsButton, sendButton, inputTextView ] UIView.animate( withDuration: 0.25, @@ -696,7 +740,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele ) } - @MainActor func showMentionsUI(for candidates: [MentionSelectionView.ViewModel]) { + @MainActor public func showMentionsUI(for candidates: [MentionSelectionView.ViewModel]) { mentionsView.candidates = candidates let mentionCellHeight = (ProfilePictureView.Info.Size.message.viewSize + 2 * Values.smallSpacing) @@ -710,7 +754,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele } } - @MainActor func hideMentionsUI() { + @MainActor public func hideMentionsUI() { UIView.animate( withDuration: 0.15, animations: { [weak self] in diff --git a/SessionUIKit/Components/LinkPreviewView.swift b/SessionUIKit/Components/LinkPreviewView.swift index 679b3d5e03..6c1bfee95c 100644 --- a/SessionUIKit/Components/LinkPreviewView.swift +++ b/SessionUIKit/Components/LinkPreviewView.swift @@ -12,6 +12,12 @@ public struct LinkPreviewViewModel { case sent } + public enum LoadResult { + case success(LinkPreviewViewModel) + case error(Error) + case obsolete + } + public var state: State public var urlString: String public var title: String? diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 9ebbcc4a77..679a9314a9 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -25,6 +25,8 @@ public actor SNUIKit { func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary func mediaDecoderSource(for url: URL) -> CGImageSource? func mediaDecoderSource(for data: Data) -> CGImageSource? + + @MainActor func numberOfCharactersLeft(for text: String) -> Int } @MainActor public static var mainWindow: UIWindow? = nil @@ -129,4 +131,11 @@ public actor SNUIKit { return config?.mediaDecoderSource(for: data) } + + @MainActor internal static func numberOfCharactersLeft(for text: String) -> Int { + configLock.lock() + defer { configLock.unlock() } + + return (config?.numberOfCharactersLeft(for: text) ?? 0) + } } diff --git a/SessionUIKit/Types/LinkPreviewManagerType.swift b/SessionUIKit/Types/LinkPreviewManagerType.swift index c6ae46971e..5f12bf0542 100644 --- a/SessionUIKit/Types/LinkPreviewManagerType.swift +++ b/SessionUIKit/Types/LinkPreviewManagerType.swift @@ -11,6 +11,7 @@ public protocol LinkPreviewManagerType { func setHasSeenLinkPreviewSuggestion(_ value: Bool) async func allPreviewUrls(forMessageBodyText body: String) async -> [String] func previewUrl(for text: String?, selectedRange: NSRange?) async -> String? + func ensureLinkPreviewsEnabled() async throws func tryToBuildPreviewInfo(previewUrl: String, skipImageDownload: Bool) async throws -> LinkPreviewViewModel } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift deleted file mode 100644 index 5962a48bb0..0000000000 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import UIKit -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit - -protocol AttachmentApprovalInputAccessoryViewDelegate: AnyObject { - func attachmentApprovalInputUpdateMediaRail() -} - -// MARK: - - -class AttachmentApprovalInputAccessoryView: UIView { - - weak var delegate: AttachmentApprovalInputAccessoryViewDelegate? - - let attachmentTextToolbar: AttachmentTextToolbar - let galleryRailView: GalleryRailView - var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } - - var isEditingMediaMessage: Bool { - return attachmentTextToolbar.inputView?.isFirstResponder ?? false - } - - private var currentAttachmentItem: PendingAttachmentRailItem? - - let kGalleryRailViewHeight: CGFloat = 72 - - required init(delegate: AttachmentTextToolbarDelegate, using dependencies: Dependencies) { - attachmentTextToolbar = AttachmentTextToolbar(delegate: delegate, using: dependencies) - - galleryRailView = GalleryRailView() - galleryRailView.scrollFocusMode = .keepWithinBounds - galleryRailView.set(.height, to: kGalleryRailViewHeight) - - super.init(frame: .zero) - - createContents() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func createContents() { - // Specifying auto-resizing mask and an intrinsic content size allows proper - // sizing when used as an input accessory view. - self.autoresizingMask = .flexibleHeight - self.translatesAutoresizingMaskIntoConstraints = false - self.themeBackgroundColor = .clear - - preservesSuperviewLayoutMargins = true - - // Use a background view that extends below the keyboard to avoid animation glitches. - let backgroundView = UIView() - backgroundView.themeBackgroundColor = .backgroundPrimary - addSubview(backgroundView) - backgroundView.pin(to: self) - - // Separator - let separator = UIView.separator() - addSubview(separator) - separator.pin(.top, to: .top, of: self) - separator.pin(.leading, to: .leading, of: self) - separator.pin(.trailing, to: .trailing, of: self) - - let stackView = UIStackView(arrangedSubviews: [galleryRailView, attachmentTextToolbar]) - stackView.axis = .vertical - - addSubview(stackView) - stackView.pin(.top, to: .top, of: self) - stackView.pin(.leading, to: .leading, of: self) - stackView.pin(.trailing, to: .trailing, of: self) - // We pin to the superview's _margin_. Otherwise the notch breaks - // the layout if you hide the keyboard in the simulator (or if the - // user uses an external keyboard). - stackView.pin(.bottom, toMargin: .bottom, of: self) - - let galleryRailBlockingView: UIView = UIView() - galleryRailBlockingView.themeBackgroundColor = .backgroundPrimary - stackView.addSubview(galleryRailBlockingView) - galleryRailBlockingView.pin(.top, to: .bottom, of: attachmentTextToolbar) - galleryRailBlockingView.pin(.left, to: .left, of: stackView) - galleryRailBlockingView.pin(.right, to: .right, of: stackView) - galleryRailBlockingView.pin(.bottom, to: .bottom, of: stackView) - } - - // MARK: - - private var shouldHideControls = false - - private func updateFirstResponder() { - if (shouldHideControls) { - attachmentTextToolbar.inputView?.resignFirstResponder() - } - } - - public func update(currentAttachmentItem: PendingAttachmentRailItem?, shouldHideControls: Bool) { - self.currentAttachmentItem = currentAttachmentItem - self.shouldHideControls = shouldHideControls - - updateFirstResponder() - } - - // MARK: - - override var intrinsicContentSize: CGSize { - get { - // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify - // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. - return CGSize.zero - } - } - - public var hasFirstResponder: Bool { - return (isFirstResponder || attachmentTextToolbar.inputView?.isFirstResponder ?? false) - } -} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 178bda125d..16c47bc1ef 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -24,7 +24,8 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, - messageText: String? + messageText: String?, + quoteViewModel: QuoteViewModel? ) func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) @@ -69,14 +70,16 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let threadId: String private let threadVariant: SessionThread.Variant private let isAddMoreVisible: Bool - private var quoteDraft: QuoteViewModel? + private let initialMessageText: String + private var quoteViewModel: QuoteViewModel? + private let onQuoteCancelled: (() -> Void)? private var isSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } var isKeyboardVisible: Bool = false private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)? + private let didLoadLinkPreview: ((LinkPreviewViewModel.LoadResult) -> Void)? public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? @@ -116,53 +119,49 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return pageViewController.shouldHideControls } - - override public var inputAccessoryView: UIView? { - bottomToolView.layoutIfNeeded() - return bottomToolView - } override public var canBecomeFirstResponder: Bool { return !shouldHideControls } public var messageText: String? { - get { return bottomToolView.attachmentTextToolbar.text } - set { bottomToolView.attachmentTextToolbar.text = newValue } + get { return snInputView.text } + set { snInputView.text = (newValue ?? "") } } // MARK: - Initializers - @available(*, unavailable, message:"use attachment: constructor instead.") - required public init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - required public init?( + required public init( mode: Mode, + delegate: AttachmentApprovalViewControllerDelegate?, threadId: String, threadVariant: SessionThread.Variant, attachments: [PendingAttachment], - quoteDraft: QuoteViewModel?, + messageText: String?, + quoteViewModel: QuoteViewModel?, disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)?, + didLoadLinkPreview: ((LinkPreviewViewModel.LoadResult) -> Void)?, + onQuoteCancelled: (() -> Void)?, using dependencies: Dependencies ) { - guard !attachments.isEmpty else { return nil } - self.dependencies = dependencies self.mode = mode + self.approvalDelegate = delegate self.threadId = threadId self.threadVariant = threadVariant let attachmentItems = attachments.map { PendingAttachmentRailItem(attachment: $0, using: dependencies) } - self.quoteDraft = quoteDraft + self.initialMessageText = (messageText ?? "") + self.quoteViewModel = quoteViewModel self.isAddMoreVisible = (mode == .sharedNavigation) self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload self.didLoadLinkPreview = didLoadLinkPreview - - self.attachmentRailItemCollection = PendingAttachmentRailItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible) + self.attachmentRailItemCollection = PendingAttachmentRailItemCollection( + attachmentItems: attachmentItems, + isAddMoreVisible: isAddMoreVisible + ) + self.onQuoteCancelled = onQuoteCancelled super.init( transitionStyle: .scroll, @@ -181,44 +180,23 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC object: nil ) } + + @available(*, unavailable, message:"use attachment: constructor instead.") + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } deinit { NotificationCenter.default.removeObserver(self) } - public class func wrappedInNavController( - threadId: String, - threadVariant: SessionThread.Variant, - attachments: [PendingAttachment], - quoteDraft: QuoteViewModel?, - approvalDelegate: AttachmentApprovalViewControllerDelegate, - disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)?, - using dependencies: Dependencies - ) -> UINavigationController? { - guard let vc = AttachmentApprovalViewController( - mode: .modal, - threadId: threadId, - threadVariant: threadVariant, - attachments: attachments, - quoteDraft: quoteDraft, - disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, - didLoadLinkPreview: didLoadLinkPreview, - using: dependencies - ) else { return nil } - vc.approvalDelegate = approvalDelegate - - let navController = StyledNavigationController(rootViewController: vc) - - return navController - } - // MARK: - UI private let kSpacingBetweenItems: CGFloat = 20 lazy var footerControlsStackView: UIStackView = { let result: UIStackView = UIStackView(arrangedSubviews: [ + galleryRailTopSeparator, galleryRailView, snInputView ]) @@ -229,27 +207,87 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return result }() - private lazy var bottomToolView: AttachmentApprovalInputAccessoryView = { - let bottomToolView = AttachmentApprovalInputAccessoryView(delegate: self, using: dependencies) - bottomToolView.delegate = self - bottomToolView.attachmentTextToolbar.delegate = self - bottomToolView.galleryRailView.delegate = self - - return bottomToolView + private lazy var galleryRailTopSeparator: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .borderSeparator + result.set(.height, to: Values.separatorThickness) + + return result }() - private var galleryRailView: GalleryRailView { return bottomToolView.galleryRailView } + private lazy var galleryRailView: GalleryRailView = { + let result: GalleryRailView = GalleryRailView() + result.scrollFocusMode = .keepWithinBounds + result.delegate = self + result.set(.height, to: 72) + + return result + }() - private lazy var snInputView: InputView = InputView( - delegate: self, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ), - imageDataManager: dependencies[singleton: .imageDataManager], - linkPreviewManager: dependencies[singleton: .linkPreviewManager], - sessionProState: dependencies[singleton: .sessionProState] - ) + private lazy var snInputView: InputView = { + let result: InputView = InputView( + delegate: self, + displayNameRetriever: Profile.defaultDisplayNameRetriever( + threadVariant: threadVariant, + using: dependencies + ), + imageDataManager: dependencies[singleton: .imageDataManager], + linkPreviewManager: dependencies[singleton: .linkPreviewManager], + sessionProState: dependencies[singleton: .sessionProState], + onQuoteCancelled: onQuoteCancelled, + didLoadLinkPreview: { [weak self] result in + self?.didLoadLinkPreview?(result) + + switch result { + case .error(let error): + /// In the case of an error we want to update the `MediaMessageView` to show the error + self?.viewControllers?.forEach { viewController in + guard let prepViewController: AttachmentPrepViewController = viewController as? AttachmentPrepViewController else { + return + } + + switch error { + case LinkPreviewError.featureDisabled: + prepViewController.mediaMessageView.setError( + title: "linkPreviewsTurnedOff".localized(), + subtitle: "linkPreviewsTurnedOffDescription" + .put(key: "app_name", value: Constants.app_name) + .localized() + ) + + case LinkPreviewError.insecureLink: + prepViewController.mediaMessageView.setError( + title: nil, + subtitle: "linkPreviewsErrorUnsecure".localized() + ) + + default: + prepViewController.mediaMessageView.setError( + title: nil, + subtitle: "linkPreviewsErrorLoad".localized() + ) + } + } + + default: break + } + } + ) + result.text = initialMessageText + result.setMessageInputState( + InputView.InputState( + inputs: { + guard !disableLinkPreviewImageDownload else { return [.text] } + + return [.text, .attachmentsHidden] + }(), + alwaysShowSendButton: true + ) + ) + result.quoteViewModel = quoteViewModel + + return result + }() lazy var inputBackgroundView: UIView = { let result: UIView = UIView() @@ -287,6 +325,19 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.view.themeBackgroundColor = .newConversation_background + // Message requests view & scroll to bottom + view.addSubview(inputBackgroundView) + view.addSubview(footerControlsStackView) + + footerControlsStackView.pin(.leading, to: .leading, of: view) + footerControlsStackView.pin(.trailing, to: .trailing, of: view) + footerControlsStackView.pin(.bottom, to: .top, of: view.keyboardLayoutGuide) + + inputBackgroundView.pin(.top, to: .top, of: footerControlsStackView) + inputBackgroundView.pin(.leading, to: .leading, of: view) + inputBackgroundView.pin(.trailing, to: .trailing, of: view) + inputBackgroundView.pin(.bottom, to: .bottom, of: view) + // Avoid an unpleasant "bounce" which doesn't make sense in the context of a single item. pagerScrollView?.isScrollEnabled = (attachmentItems.count > 1) @@ -317,7 +368,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC false ) if firstItemIsPlainText || hasNoLinkPreview { - bottomToolView.attachmentTextToolbar.text = firstItem.attachment.toText() + snInputView.text = (firstItem.attachment.toText() ?? "") } } } @@ -346,30 +397,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC @MainActor private func updateContents() { updateNavigationBar() - updateInputAccessory() - } - - // MARK: - Input Accessory - - @MainActor public func updateInputAccessory() { - var currentPageViewController: AttachmentPrepViewController? - - if pageViewControllers?.count == 1 { - currentPageViewController = pageViewControllers?.first - } - let currentAttachmentItem: PendingAttachmentRailItem? = currentPageViewController?.attachmentItem - - let hasPresentedView = (self.presentedViewController != nil) - let isToolbarFirstResponder = bottomToolView.hasFirstResponder - - if !shouldHideControls, !isFirstResponder, !hasPresentedView, !isToolbarFirstResponder { - becomeFirstResponder() - } - - bottomToolView.update( - currentAttachmentItem: currentAttachmentItem, - shouldHideControls: shouldHideControls - ) } // MARK: - Navigation Bar @@ -510,12 +537,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } Log.debug(.cat, "Cache miss.") - let viewController = AttachmentPrepViewController( - attachmentItem: item, - disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, - didLoadLinkPreview: didLoadLinkPreview, - using: dependencies - ) + let viewController = AttachmentPrepViewController(attachmentItem: item, using: dependencies) viewController.prepDelegate = self cachedPages[item.uniqueIdentifier] = viewController @@ -576,6 +598,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC else { galleryRailView.isHidden = true } + + galleryRailTopSeparator.isHidden = galleryRailView.isHidden } // For any attachments edited with the image editor, returns a @@ -669,35 +693,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return nextItem } - - func hideInputAccessoryView() { - guard Thread.isMainThread else { - DispatchQueue.main.async { - self.hideInputAccessoryView() - } - return - } - self.isKeyboardVisible = self.bottomToolView.isEditingMediaMessage - self.inputAccessoryView?.resignFirstResponder() - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 - } - - func showInputAccessoryView() { - guard Thread.isMainThread else { - DispatchQueue.main.async { - self.showInputAccessoryView() - } - return - } - UIView.animate(withDuration: 0.25, animations: { - self.inputAccessoryView?.isHidden = false - self.inputAccessoryView?.alpha = 1 - if self.isKeyboardVisible { - self.inputAccessoryView?.becomeFirstResponder() - } - }) - } // MARK: - Event Handlers @@ -709,8 +704,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, presenting: { [weak self] modal in self?.present(modal, animated: true) @@ -719,7 +713,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return } - self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modalMessageCharacterTooLongTitle".localized(), @@ -730,25 +723,32 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC scrollMode: .never ), cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - afterClosed: { [weak self] in - self?.showInputAccessoryView() - } + cancelStyle: .alert_text ) ) present(confirmationModal, animated: true, completion: nil) } } -// MARK: - AttachmentTextToolbarDelegate - -extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { - @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { +// MARK: - InputViewDelegate + +extension AttachmentApprovalViewController: InputViewDelegate { + public func showLinkPreviewSuggestionModal() {} + public func handleDisabledInputTapped() {} + public func handleAttachmentButtonTapped() {} + public func handleDisabledAttachmentButtonTapped() {} + public func handleDisabledVoiceMessageButtonTapped() {} + public func handleMentionSelected(_ viewModel: MentionSelectionView.ViewModel, from view: MentionSelectionView) {} + public func didPasteImageDataFromPasteboard(_ imageData: Data) {} + public func startVoiceMessageRecording() {} + public func endVoiceMessageRecording() {} + public func cancelVoiceMessageRecording() {} + + public func handleCharacterLimitLabelTapped() { guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, presenting: { [weak self] modal in self?.present(modal, animated: true) @@ -757,7 +757,6 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { return } - self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modalMessageCharacterTooLongTitle".localized(), @@ -768,20 +767,16 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { scrollMode: .never ), cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - afterClosed: { [weak self] in - self?.showInputAccessoryView() - } + cancelStyle: .alert_text ) ) present(confirmationModal, animated: true, completion: nil) } - @MainActor func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) { + public func handleSendButtonTapped() { guard - let text = attachmentTextToolbar.text, LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), + for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), isSessionPro: isSessionPro ) >= 0 else { @@ -793,20 +788,19 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { // and remains visible momentarily after share extension is dismissed. // It's easiest to just hide it at this point since we're done with it. currentPageViewController?.shouldAllowAttachmentViewResizing = false - attachmentTextToolbar.isUserInteractionEnabled = false - attachmentTextToolbar.isHidden = true approvalDelegate?.attachmentApproval( self, didApproveAttachments: attachments, forThreadId: threadId, threadVariant: threadVariant, - messageText: attachmentTextToolbar.text + messageText: snInputView.text, + quoteViewModel: snInputView.quoteViewModel ) } - @MainActor func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) { - approvalDelegate?.attachmentApproval(self, didChangeMessageText: attachmentTextToolbar.text) + public func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + approvalDelegate?.attachmentApproval(self, didChangeMessageText: inputTextView.text) } } @@ -816,10 +810,6 @@ extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate @MainActor func prepViewControllerUpdateNavigationBar() { updateNavigationBar() } - - @MainActor func prepViewControllerUpdateControls() { - updateInputAccessory() - } } // MARK: GalleryRail @@ -906,11 +896,3 @@ extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate { return self.attachmentItems.count > 1 } } - -// MARK: - - -extension AttachmentApprovalViewController: AttachmentApprovalInputAccessoryViewDelegate { - public func attachmentApprovalInputUpdateMediaRail() { - updateMediaRail() - } -} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 1cbf13d70e..51e8ea534e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -10,7 +10,6 @@ import SessionUtilitiesKit protocol AttachmentPrepViewControllerDelegate: AnyObject { @MainActor func prepViewControllerUpdateNavigationBar() - @MainActor func prepViewControllerUpdateControls() } // MARK: - @@ -29,8 +28,6 @@ public class AttachmentPrepViewController: OWSViewController { let attachmentItem: PendingAttachmentRailItem var attachment: PendingAttachment { return attachmentItem.attachment } - private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)? // MARK: - UI @@ -58,16 +55,14 @@ public class AttachmentPrepViewController: OWSViewController { return view }() - private lazy var mediaMessageView: MediaMessageView = { + internal lazy var mediaMessageView: MediaMessageView = { let view: MediaMessageView = MediaMessageView( attachment: attachment, mode: .attachmentApproval, - disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, - didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) view.translatesAutoresizingMaskIntoConstraints = false - view.isHidden = (imageEditorView != nil) + view.isHidden = (imageEditorView != nil && !attachmentItem.attachment.utType.conforms(to: .url)) return view }() @@ -103,14 +98,10 @@ public class AttachmentPrepViewController: OWSViewController { init( attachmentItem: PendingAttachmentRailItem, - disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewViewModel) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies self.attachmentItem = attachmentItem - self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload - self.didLoadLinkPreview = didLoadLinkPreview super.init(nibName: nil, bundle: nil) } @@ -152,14 +143,12 @@ public class AttachmentPrepViewController: OWSViewController { super.viewWillAppear(animated) prepDelegate?.prepViewControllerUpdateNavigationBar() - prepDelegate?.prepViewControllerUpdateControls() } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) prepDelegate?.prepViewControllerUpdateNavigationBar() - prepDelegate?.prepViewControllerUpdateControls() } override public func viewWillLayoutSubviews() { @@ -413,8 +402,4 @@ extension AttachmentPrepViewController: ImageEditorViewDelegate { public func imageEditorUpdateNavigationBar() { prepDelegate?.prepViewControllerUpdateNavigationBar() } - - public func imageEditorUpdateControls() { - prepDelegate?.prepViewControllerUpdateControls() - } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift index 5fb188a3ac..f0b7645a80 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift @@ -9,7 +9,6 @@ public protocol ImageEditorViewDelegate: AnyObject { func imageEditor(presentFullScreenView viewController: UIViewController, isTransparent: Bool) func imageEditorUpdateNavigationBar() - func imageEditorUpdateControls() } // MARK: - @@ -157,10 +156,6 @@ public class ImageEditorView: UIView { return buttons } - private func updateControls() { - delegate?.imageEditorUpdateControls() - } - public var shouldHideControls: Bool { // Hide controls during "text item move". return movingTextItem != nil @@ -323,7 +318,6 @@ public class ImageEditorView: UIView { private var movingTextItem: ImageEditorTextItem? { didSet { updateNavigationBar() - updateControls() } } private var movingTextStartUnitCenter: CGPoint? diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index de0687c0aa..a7bde4322a 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -20,10 +20,6 @@ public class MediaMessageView: UIView { private let dependencies: Dependencies public let mode: Mode public let attachment: PendingAttachment - private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: (@MainActor (LinkPreviewViewModel) -> Void)? - private var linkPreviewViewModel: LinkPreviewViewModel? - private var linkPreviewLoadTask: Task<Void, Never>? // MARK: Initializers @@ -37,35 +33,20 @@ public class MediaMessageView: UIView { @MainActor public required init( attachment: PendingAttachment, mode: MediaMessageView.Mode, - disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: (@MainActor (LinkPreviewViewModel) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies self.attachment = attachment self.mode = mode - self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload - self.didLoadLinkPreview = didLoadLinkPreview - - // Set the linkPreviewViewModel if it's a url - if - attachment.utType.conforms(to: .url), - let attachmentText: String = attachment.toText(), - let linkPreviewURL: String = LinkPreview.previewUrl(for: attachmentText, using: dependencies) - { - self.linkPreviewViewModel = LinkPreviewViewModel(state: .loading, urlString: linkPreviewURL) - } super.init(frame: CGRect.zero) - + setupViews(using: dependencies) setupLayout() } deinit { NotificationCenter.default.removeObserver(self) - - linkPreviewLoadTask?.cancel() } // MARK: - UI @@ -111,7 +92,6 @@ public class MediaMessageView: UIView { ) view.translatesAutoresizingMaskIntoConstraints = false view.contentMode = .scaleAspectFit - view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate) view.themeTintColor = .textPrimary // Override the image to the correct one @@ -120,13 +100,8 @@ public class MediaMessageView: UIView { view.layer.magnificationFilter = .trilinear view.loadImage(source) } - else if attachment.utType.conforms(to: .url) { - view.clipsToBounds = true - view.image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) - view.themeTintColor = .messageBubble_outgoingText - view.contentMode = .center - view.themeBackgroundColor = .messageBubble_overlay - view.layer.cornerRadius = 8 + else if !attachment.utType.conforms(to: .url) { + view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate) } return view @@ -140,12 +115,19 @@ public class MediaMessageView: UIView { return view }() + private lazy var titleSeparator: UIView = { + let result: UIView = UIView.vhSpacer(10, 10) + result.isHidden = !titleLabel.isHidden + + return result + }() + private lazy var titleStackView: UIStackView = { let stackView: UIStackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical - stackView.alignment = (attachment.utType.conforms(to: .url) && linkPreviewViewModel?.state == .loading ? .leading : .center) stackView.distribution = .fill + stackView.alignment = .center switch mode { case .attachmentApproval: stackView.spacing = 2 @@ -175,22 +157,8 @@ public class MediaMessageView: UIView { label.themeTextColor = .primary } - // Content - if attachment.utType.conforms(to: .url) { - // If we have no link preview info at this point then assume link previews are disabled - if let linkPreviewURL: String = linkPreviewViewModel?.urlString { - label.font = .boldSystemFont(ofSize: Values.smallFontSize) - label.text = linkPreviewURL - label.textAlignment = .left - label.lineBreakMode = .byTruncatingTail - label.numberOfLines = 2 - } - else { - label.text = "linkPreviewsTurnedOff".localized() - } - } // Title for everything except these types - else if !attachment.isValidVisualMedia { + if !attachment.utType.conforms(to: .url) && !attachment.isValidVisualMedia { if let fileName: String = attachment.sourceFilename?.trimmingCharacters(in: .whitespacesAndNewlines), fileName.count > 0 { label.text = fileName } @@ -227,33 +195,8 @@ public class MediaMessageView: UIView { label.themeTextColor = .primary } - // Content - if attachment.utType.conforms(to: .url) { - // We only load Link Previews for HTTPS urls so append an explanation for not - if let linkPreviewURL: String = linkPreviewViewModel?.urlString { - let httpsScheme: String = "https" // stringlint:ignore - - if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { - label.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) - label.text = "linkPreviewsErrorUnsecure".localized() - label.themeTextColor = (mode == .attachmentApproval ? - .textSecondary : - .primary - ) - } - } - // If we have no link preview info at this point then assume link previews are disabled - else { - label.text = "linkPreviewsTurnedOffDescription" - .put(key: "app_name", value: Constants.app_name) - .localized() - label.themeTextColor = .textPrimary - label.textAlignment = .center - label.numberOfLines = 0 - } - } // Subtitle for everything else except these types - else if !attachment.isValidVisualMedia { + if !attachment.utType.conforms(to: .url) && !attachment.isValidVisualMedia { // Format string for file size label in call interstitial view. // Embeds: {{file size as 'N mb' or 'N kb'}}. let fileSize: UInt = UInt(attachment.fileSize) @@ -274,6 +217,7 @@ public class MediaMessageView: UIView { @MainActor private func setupViews(using dependencies: Dependencies) { switch attachment.source { + case .text where attachment.utType.conforms(to: .url): break /// URLs should be handled case .text: return /// Plain text will just be put in the 'message' input so do nothing default: break } @@ -283,7 +227,7 @@ public class MediaMessageView: UIView { addSubview(loadingView) stackView.addArrangedSubview(imageView) - if !titleLabel.isHidden { stackView.addArrangedSubview(UIView.vhSpacer(10, 10)) } + stackView.addArrangedSubview(titleSeparator) stackView.addArrangedSubview(titleStackView) titleStackView.addArrangedSubview(titleLabel) @@ -300,29 +244,14 @@ public class MediaMessageView: UIView { fileTypeImageView.themeTintColor = .textPrimary fileTypeImageView.isHidden = false } - else if attachment.utType.conforms(to: .url) { - imageView.alpha = 0 // Not 'isHidden' because we want it to take up space in the UIStackView - loadingView.isHidden = false - - if let linkPreviewUrl: String = linkPreviewViewModel?.urlString { - // Don't want to change the axis until we have a URL to start loading, otherwise the - // error message will be broken - stackView.axis = .horizontal - - loadLinkPreview( - linkPreviewUrl: linkPreviewUrl, - skipImageDownload: disableLinkPreviewImageDownload, - using: dependencies - ) - } - } - else { + else if !attachment.utType.conforms(to: .url) { imageView.set(.width, to: .width, of: stackView) } } @MainActor private func setupLayout() { switch attachment.source { + case .text where attachment.utType.conforms(to: .url): break /// URLs should be handled case .text: return /// Plain text will just be put in the 'message' input so do nothing default: break } @@ -352,7 +281,7 @@ public class MediaMessageView: UIView { // If we don't have a valid image then use the 'generic' case } else if attachment.utType.conforms(to: .url) { - return 80 + return nil } // Generic file size @@ -372,11 +301,7 @@ public class MediaMessageView: UIView { stackView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor), (maybeImageSize != nil ? - stackView.widthAnchor.constraint( - equalTo: widthAnchor, - // Inset stackView for urls - constant: (attachment.utType.conforms(to: .url) ? -(32 * 2) : 0) - ) : + stackView.widthAnchor.constraint(equalTo: widthAnchor) : stackView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor) ), @@ -423,66 +348,37 @@ public class MediaMessageView: UIView { } } - // MARK: - Link Loading - - @MainActor private func loadLinkPreview( - linkPreviewUrl: String, - skipImageDownload: Bool, - using dependencies: Dependencies - ) { - loadingView.startAnimating() - - linkPreviewLoadTask?.cancel() - linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self] in - do { - let viewModel: LinkPreviewViewModel = try await dependencies[singleton: .linkPreviewManager].tryToBuildPreviewInfo( - previewUrl: linkPreviewUrl, - skipImageDownload: skipImageDownload - ) + @MainActor public func setError(title: String?, subtitle: String?) { + switch (title, subtitle) { + case (.some(let title), .some(let subtitle)): + titleLabel.text = title + titleLabel.isHidden = false + titleSeparator.isHidden = false + subtitleLabel.text = subtitle + subtitleLabel.themeTextColor = .textPrimary + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + subtitleLabel.isHidden = false - await MainActor.run { [weak self] in - guard let self else { return } - - didLoadLinkPreview?(viewModel) - linkPreviewViewModel = viewModel - - // Update the UI - titleLabel.text = (viewModel.title ?? titleLabel.text) - loadingView.alpha = 0 - loadingView.stopAnimating() - imageView.alpha = 1 - - if let imageSource: ImageDataManager.DataSource = viewModel.imageSource { - imageView.loadImage(imageSource) - } - } - } - catch { - await MainActor.run { [weak self] in - guard let self else { return } - - loadingView.alpha = 0 - loadingView.stopAnimating() - imageView.alpha = 1 - titleLabel.numberOfLines = 1 /// Truncates the URL at 1 line so the error is more readable - subtitleLabel.isHidden = false - - /// Set the error text appropriately - let httpsScheme: String = "https" // stringlint:ignore - if URLComponents(string: linkPreviewUrl)?.scheme?.lowercased() != httpsScheme { - // This error case is handled already in the 'subtitleLabel' creation - } - else { - subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) - subtitleLabel.text = "linkPreviewsErrorLoad".localized() - subtitleLabel.themeTextColor = (mode == .attachmentApproval ? - .textSecondary : - .primary - ) - subtitleLabel.textAlignment = .left - } - } - } + case (.some(let title), .none): + titleLabel.text = title + titleLabel.isHidden = false + titleSeparator.isHidden = true + subtitleLabel.isHidden = true + + case (.none, .some(let subtitle)): + titleLabel.isHidden = true + titleSeparator.isHidden = true + subtitleLabel.text = subtitle + subtitleLabel.themeTextColor = .textSecondary + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + subtitleLabel.isHidden = false + + case (.none, .none): + titleLabel.isHidden = true + titleSeparator.isHidden = true + subtitleLabel.isHidden = true } } }