From 8f120c43804eb63a22a71295947318a0cb535bec Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 13 May 2022 18:07:24 +1000 Subject: [PATCH] Started re-adding media gallery interactions (in progress) Fixed up quote attachment sending and retrieval Validated attachment sending and retrieving is working correctly Re-added the AttachmentUploadJob migration --- Session.xcodeproj/project.pbxproj | 36 +- .../ConversationVC+Interaction.swift | 180 +++- .../Content Views/MediaAlbumView.swift | 359 +++---- .../Content Views/MediaPlaceholderView.swift | 63 +- .../Content Views/MediaView.swift | 400 ++++---- .../Content Views/QuoteView.swift | 2 +- .../Message Cells/VisibleMessageCell.swift | 875 +++++++++++------- .../OWSConversationSettingsViewController.m | 12 +- .../ImagePickerController.swift | 8 +- .../MediaGalleryViewModel.swift | 55 ++ .../MediaTileViewController.swift | 14 +- .../PhotoGridViewCell.swift | 7 +- .../MediaDismissAnimationController.swift | 234 +++++ .../Transitions/MediaInteractiveDismiss.swift | 108 +++ .../MediaPresentationContext.swift | 50 + .../MediaZoomAnimationController.swift | 189 ++++ .../Utilities/UINavigationBar+Utilities.swift | 44 + .../LegacyDatabase/SMKLegacyModels.swift | 4 +- .../_001_InitialSetupMigration.swift | 3 + .../Migrations/_003_YDBToGRDBMigration.swift | 33 +- .../Database/Models/Attachment.swift | 127 ++- .../Models/InteractionAttachment.swift | 2 +- .../Database/Models/LinkPreview.swift | 2 +- .../Database/Models/Quote.swift | 39 +- .../Jobs/Types/AttachmentDownloadJob.swift | 2 +- .../Jobs/Types/AttachmentUploadJob.swift | 12 +- .../Jobs/Types/MessageSendJob.swift | 1 + .../Signal/TypingIndicatorInteraction.swift | 48 - .../MessageSender+ClosedGroups.swift | 9 - .../MessageSender+Convenience.swift | 13 +- .../Sending & Receiving/MessageSender.swift | 98 +- .../Quotes/QuotedReplyModel.swift | 13 + SessionUtilitiesKit/Database/Models/Job.swift | 12 +- .../General/ReusableView.swift | 1 + .../UICollectionView+ReusableView.swift | 27 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 27 +- .../Shared Views/GalleryRailView.swift | 4 +- 37 files changed, 2091 insertions(+), 1022 deletions(-) create mode 100644 Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift create mode 100644 Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift create mode 100644 Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift create mode 100644 Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift create mode 100644 Session/Utilities/UINavigationBar+Utilities.swift delete mode 100644 SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift create mode 100644 SessionUtilitiesKit/General/UICollectionView+ReusableView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7744b88ab..81cd9273a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -214,7 +214,6 @@ B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; - B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; }; B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.swift */; }; @@ -750,6 +749,7 @@ FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */; }; + FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; }; FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; @@ -784,6 +784,7 @@ FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; + FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; }; FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; }; @@ -802,6 +803,10 @@ FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */; }; FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */; }; FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; + FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */; }; + FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */; }; + FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE127282D05530098B17F /* MediaPresentationContext.swift */; }; + FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1006,7 +1011,6 @@ 34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; - 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = ""; }; 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = ""; }; 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerLayout.swift; sourceTree = ""; }; 34C3C78C20409F320000134C /* Opening.m4r */ = {isa = PBXFileReference; lastKnownFileType = file; path = Opening.m4r; sourceTree = ""; }; @@ -1798,6 +1802,7 @@ FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = ""; }; + FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = ""; }; @@ -1829,6 +1834,7 @@ FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = ""; }; FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = ""; }; + FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = ""; }; FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; @@ -1849,6 +1855,10 @@ FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TableRecord+Utilities.swift"; sourceTree = ""; }; FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDismissAnimationController.swift; sourceTree = ""; }; + FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaInteractiveDismiss.swift; sourceTree = ""; }; + FDFDE127282D05530098B17F /* MediaPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPresentationContext.swift; sourceTree = ""; }; + FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2080,6 +2090,7 @@ B886B4A82398BA1500211ABE /* QRCode.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, + FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, C31A6C59247F214E001123EF /* UIView+Glow.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, FD859EFF27C4691300510D0C /* MockDataGenerator.swift */, @@ -2396,6 +2407,7 @@ C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, FD705A91278D051200F16121 /* ReusableView.swift */, + FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */, FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */, C38EF23D255B6D66007E1867 /* UIView+OWS.h */, C38EF23E255B6D66007E1867 /* UIView+OWS.m */, @@ -2613,7 +2625,6 @@ C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */, C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */, B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */, - 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, ); path = Signal; sourceTree = ""; @@ -2890,6 +2901,7 @@ C36096BA25AD1B14008B62B2 /* Media Viewing & Editing */ = { isa = PBXGroup; children = ( + FDFDE122282D04E30098B17F /* Transitions */, C36096B925AD1ACF008B62B2 /* GIFs */, 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */, @@ -3755,6 +3767,17 @@ path = Errors; sourceTree = ""; }; + FDFDE122282D04E30098B17F /* Transitions */ = { + isa = PBXGroup; + children = ( + FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */, + FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */, + FDFDE127282D05530098B17F /* MediaPresentationContext.swift */, + FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */, + ); + path = Transitions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4749,6 +4772,7 @@ B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */, FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */, FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */, + FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */, FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, @@ -4945,7 +4969,6 @@ C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */, C352A30925574D8500338F3E /* Message+Destination.swift in Sources */, C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */, - B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */, C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, @@ -4991,6 +5014,7 @@ FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, + FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, @@ -5043,9 +5067,12 @@ 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */, B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, + FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, + FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */, + FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */, C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */, B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */, @@ -5134,6 +5161,7 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, + FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */, C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 0ac633d1f..3d37ebcf7 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -301,7 +301,6 @@ extension ConversationVC: let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model - for: self.thread, approveMessageRequestIfNeeded( for: thread, isNewThread: !oldThreadShouldBeVisible, @@ -332,21 +331,14 @@ extension ConversationVC: let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft, (try? interaction.linkPreview.isEmpty(db)) == true { - var attachmentId: String? - - // If the LinkPreview has image data then create an attachment first - if let imageData: Data = linkPreviewDraft.jpegImageData { - attachmentId = try LinkPreview.saveAttachmentIfPossible( - db, - imageData: imageData, - mimeType: OWSMimeTypeImageJpeg - ) - } - try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: attachmentId + attachmentId: LinkPreview.saveAttachmentIfPossible( + db, + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + ) ).insert(db) } @@ -359,14 +351,13 @@ extension ConversationVC: authorId: quoteModel.authorId, timestampMs: quoteModel.timestampMs, body: quoteModel.body, - attachmentId: quoteModel.attachment?.id + attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db) ).insert(db) } try MessageSender.send( db, interaction: interaction, - with: [], in: thread ) }, @@ -417,14 +408,14 @@ extension ConversationVC: .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) // Create the interaction - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db) let interaction: Interaction = try Interaction( threadId: thread.id, authorId: getUserHexEncodedPublicKey(db), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, - hasMention: text.contains("@\(currentUserPublicKey)") + hasMention: text.contains("@\(userPublicKey)") ).inserted(db) try MessageSender.send( @@ -668,33 +659,41 @@ extension ConversationVC: case .audio: viewModel.playOrPauseAudio(for: item) case .mediaMessage: - guard let index = viewItems.firstIndex(where: { $0 === viewItem }), - let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return } - if - viewItem.interaction is TSIncomingMessage, - let thread = self.thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - confirmDownload() - } else { - guard let albumView = cell.albumView else { return } - let locationInCell = gestureRecognizer.location(in: cell) - // Figure out which of the media views was tapped - let locationInAlbumView = cell.convert(locationInCell, to: albumView) - guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } - if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() { + guard + let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, + let albumView: MediaAlbumView = cell.albumView + else { return } + + let locationInCell: CGPoint = gestureRecognizer.location(in: cell) + + // Figure out which of the media views was tapped + let locationInAlbumView: CGPoint = cell.convert(locationInCell, to: albumView) + guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } + + + switch mediaView.attachment.state { + case .pending, .downloading, .uploading: // TODO: Tapped a failed incoming attachment - } - let attachment = mediaView.attachment - if let pointer = attachment as? TSAttachmentPointer { - if pointer.state == .failed { - // TODO: Tapped a failed incoming attachment + break + + case .failed: + // TODO: Tapped a failed incoming attachment + break + + default: + let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( + for: self.viewModel.viewData.thread.id, + item: item, + selectedAttachmentId: mediaView.attachment.id, + options: [ .sliderEnabled, .showAllMediaButton ] + ) + + if let viewController: UIViewController = viewController { + self.present(viewController, animated: true, completion: nil) } - } - guard let stream = attachment as? TSAttachmentStream else { return } - let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ]) - gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) } + case .genericAttachment: guard let attachment: Attachment = item.attachments?.first, @@ -1554,9 +1553,9 @@ extension ConversationVC { alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in // Delete the request GRDBStorage.shared.writeAsync( - updates: { [weak self] db in + updates: { db in // Update the contact - try? Contact + _ = try Contact .fetchOrCreate(db, id: threadId) .with( isApproved: false, @@ -1586,3 +1585,98 @@ extension ConversationVC { self.present(alertVC, animated: true, completion: nil) } } + +// MARK: - MediaPresentationContextProvider + +extension ConversationVC: MediaPresentationContextProvider { + func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { + guard case let .gallery(galleryItem) = mediaItem else { return nil } + // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an + // unsorted array which means we can't use it to determine the desired 'visibleCell' + // we are after, due to this we will need to iterate all of the visible cells to find + // the one we want + let maybeMessageCell: VisibleMessageCell? = tableView.visibleCells + .first { cell -> Bool in + ((cell as? VisibleMessageCell)? + .albumView? + .itemViews + .contains(where: { mediaView in + mediaView.attachment.id == galleryItem.attachment.id + })) + .defaulting(to: false) + } + .map { $0 as? VisibleMessageCell } + let maybeTargetView: MediaView? = maybeMessageCell? + .albumView? + .itemViews + .first(where: { $0.attachment.id == galleryItem.attachment.id }) + + 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 = coordinateSpace.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) && + targetView.frame.minX < CGFloat.leastNonzeroMagnitude && + targetView.frame.minY < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMinXMinYCorner) + } + + if + cellMaskedCorners.contains(.layerMaxXMinYCorner) && + abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + targetView.frame.minY < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMaxXMinYCorner) + } + + if + cellMaskedCorners.contains(.layerMinXMaxYCorner) && + targetView.frame.minX < CGFloat.leastNonzeroMagnitude && + abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMinXMaxYCorner) + } + + if + cellMaskedCorners.contains(.layerMaxXMaxYCorner) && + abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMaxXMaxYCorner) + } + + cornerMask = newCornerMask + } + + return MediaPresentationContext( + mediaView: targetView, + presentationFrame: presentationFrame, + cornerRadius: cornerRadius, + cornerMask: cornerMask + ) + } + + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + } +} diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 8fdd9f266..5600fa62b 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -1,17 +1,11 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import SessionMessagingKit -@objc(OWSMediaAlbumView) public class MediaAlbumView: UIStackView { - private let items: [ConversationMediaAlbumItem] - - @objc + private let items: [Attachment] public let itemViews: [MediaView] - - @objc public var moreItemsView: MediaView? private static let kSpacingPts: CGFloat = 2 @@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView { notImplemented() } - @objc - public required init(mediaCache: NSCache, - items: [ConversationMediaAlbumItem], - isOutgoing: Bool, - maxMessageWidth: CGFloat) { + public required init( + mediaCache: NSCache, + items: [Attachment], + isOutgoing: Bool, + maxMessageWidth: CGFloat + ) { self.items = items - self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map { - let result = MediaView(mediaCache: mediaCache, - attachment: $0.attachment, - isOutgoing: isOutgoing, - maxMessageWidth: maxMessageWidth) - return result - } + self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items) + .map { + MediaView( + mediaCache: mediaCache, + attachment: $0, + isOutgoing: isOutgoing, + maxMessageWidth: maxMessageWidth + ) + } super.init(frame: .zero) @@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView { private func createContents(maxMessageWidth: CGFloat) { switch itemViews.count { - case 0: - owsFailDebug("No item views.") - return - case 1: - // X - guard let itemView = itemViews.first else { - owsFailDebug("Missing item view.") - return - } - addSubview(itemView) - itemView.autoPinEdgesToSuperviewEdges() - case 2: - // X X - // side-by-side. - let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - autoSet(viewSize: imageSize, ofViews: itemViews) - for itemView in itemViews { - addArrangedSubview(itemView) - } - self.axis = .horizontal - self.spacing = MediaAlbumView.kSpacingPts - case 3: - // x - // X x - // Big on left, 2 small on right. - let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 - let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts - - guard let leftItemView = itemViews.first else { - owsFailDebug("Missing view") - return - } - autoSet(viewSize: bigImageSize, ofViews: [leftItemView]) - addArrangedSubview(leftItemView) - - let rightViews = Array(itemViews[1..<3]) - addArrangedSubview(newRow(rowViews: rightViews, - axis: .vertical, - viewSize: smallImageSize)) - self.axis = .horizontal - self.spacing = MediaAlbumView.kSpacingPts - case 4: - // X X - // X X - // Square - let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - - let topViews = Array(itemViews[0..<2]) - addArrangedSubview(newRow(rowViews: topViews, - axis: .horizontal, - viewSize: imageSize)) - - let bottomViews = Array(itemViews[2..<4]) - addArrangedSubview(newRow(rowViews: bottomViews, - axis: .horizontal, - viewSize: imageSize)) - - self.axis = .vertical - self.spacing = MediaAlbumView.kSpacingPts - default: - // X X - // xxx - // 2 big on top, 3 small on bottom. - let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 - - let topViews = Array(itemViews[0..<2]) - addArrangedSubview(newRow(rowViews: topViews, - axis: .horizontal, - viewSize: bigImageSize)) - - let bottomViews = Array(itemViews[2..<5]) - addArrangedSubview(newRow(rowViews: bottomViews, - axis: .horizontal, - viewSize: smallImageSize)) - - self.axis = .vertical - self.spacing = MediaAlbumView.kSpacingPts - - if items.count > MediaAlbumView.kMaxItems { - guard let lastView = bottomViews.last else { - owsFailDebug("Missing lastView") + case 0: return owsFailDebug("No item views.") + + case 1: + // X + guard let itemView = itemViews.first else { + owsFailDebug("Missing item view.") return } + addSubview(itemView) + itemView.autoPinEdgesToSuperviewEdges() + + case 2: + // X X + // side-by-side. + let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 + autoSet(viewSize: imageSize, ofViews: itemViews) + for itemView in itemViews { + addArrangedSubview(itemView) + } + self.axis = .horizontal + self.distribution = .fillEqually + self.spacing = MediaAlbumView.kSpacingPts + + case 3: + // x + // X x + // Big on left, 2 small on right. + let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 + let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts - moreItemsView = lastView + guard let leftItemView = itemViews.first else { + owsFailDebug("Missing view") + return + } + autoSet(viewSize: bigImageSize, ofViews: [leftItemView]) + addArrangedSubview(leftItemView) - let tintView = UIView() - tintView.backgroundColor = UIColor(white: 0, alpha: 0.4) - lastView.addSubview(tintView) - tintView.autoPinEdgesToSuperviewEdges() + let rightViews = Array(itemViews[1..<3]) + addArrangedSubview( + newRow( + rowViews: rightViews, + axis: .vertical, + viewSize: smallImageSize + ) + ) + self.axis = .horizontal + self.spacing = MediaAlbumView.kSpacingPts + + case 4: + // X X + // X X + // Square + let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 - let moreCount = max(1, items.count - MediaAlbumView.kMaxItems) - let moreCountText = OWSFormat.formatInt(Int32(moreCount)) - let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT", - comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText) - let moreLabel = UILabel() - moreLabel.text = moreText - moreLabel.textColor = UIColor.ows_white - // We don't want to use dynamic text here. - moreLabel.font = UIFont.systemFont(ofSize: 24) - lastView.addSubview(moreLabel) - moreLabel.autoCenterInSuperview() - } + let topViews = Array(itemViews[0..<2]) + addArrangedSubview( + newRow( + rowViews: topViews, + axis: .horizontal, + viewSize: imageSize + ) + ) + + let bottomViews = Array(itemViews[2..<4]) + addArrangedSubview( + newRow( + rowViews: bottomViews, + axis: .horizontal, + viewSize: imageSize + ) + ) + + self.axis = .vertical + self.spacing = MediaAlbumView.kSpacingPts + + default: + // X X + // xxx + // 2 big on top, 3 small on bottom. + let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2 + let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3 + + let topViews = Array(itemViews[0..<2]) + addArrangedSubview( + newRow( + rowViews: topViews, + axis: .horizontal, + viewSize: bigImageSize + ) + ) + + let bottomViews = Array(itemViews[2..<5]) + addArrangedSubview( + newRow( + rowViews: bottomViews, + axis: .horizontal, + viewSize: smallImageSize + ) + ) + + self.axis = .vertical + self.spacing = MediaAlbumView.kSpacingPts + + if items.count > MediaAlbumView.kMaxItems { + guard let lastView = bottomViews.last else { + owsFailDebug("Missing lastView") + return + } + + moreItemsView = lastView + + let tintView = UIView() + tintView.backgroundColor = UIColor(white: 0, alpha: 0.4) + lastView.addSubview(tintView) + tintView.autoPinEdgesToSuperviewEdges() + + let moreCount = max(1, items.count - MediaAlbumView.kMaxItems) + let moreCountText = OWSFormat.formatInt(Int32(moreCount)) + let moreText = String( + // Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}. + format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(), + moreCountText + ) + let moreLabel = UILabel() + moreLabel.text = moreText + moreLabel.textColor = UIColor.ows_white + // We don't want to use dynamic text here. + moreLabel.font = UIFont.systemFont(ofSize: 24) + lastView.addSubview(moreLabel) + moreLabel.autoCenterInSuperview() + } } for itemView in itemViews { @@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView { } } - private func autoSet(viewSize: CGFloat, - ofViews views: [MediaView]) { + private func autoSet( + viewSize: CGFloat, + ofViews views: [MediaView] + ) { for itemView in views { itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize)) } } - private func newRow(rowViews: [MediaView], - axis: NSLayoutConstraint.Axis, - viewSize: CGFloat) -> UIStackView { + private func newRow( + rowViews: [MediaView], + axis: NSLayoutConstraint.Axis, + viewSize: CGFloat + ) -> UIStackView { autoSet(viewSize: viewSize, ofViews: rowViews) return newRow(rowViews: rowViews, axis: axis) } - private func newRow(rowViews: [MediaView], - axis: NSLayoutConstraint.Axis) -> UIStackView { + private func newRow( + rowViews: [MediaView], + axis: NSLayoutConstraint.Axis + ) -> UIStackView { let stackView = UIStackView(arrangedSubviews: rowViews) stackView.axis = axis stackView.spacing = MediaAlbumView.kSpacingPts return stackView } - @objc public func loadMedia() { for itemView in itemViews { itemView.loadMedia() } } - @objc public func unloadMedia() { for itemView in itemViews { itemView.unloadMedia() } } - private class func itemsToDisplay(forItems items: [ConversationMediaAlbumItem]) -> [ConversationMediaAlbumItem] { + private class func itemsToDisplay(forItems items: [Attachment]) -> [Attachment] { // TODO: Unless design changes, we want to display // items which are still downloading and invalid // items. @@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView { return validItems } - @objc - public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat, - items: [ConversationMediaAlbumItem]) -> CGSize { + public class func layoutSize( + forMaxMessageWidth maxMessageWidth: CGFloat, + items: [Attachment] + ) -> CGSize { let itemCount = itemsToDisplay(forItems: items).count + switch itemCount { - case 0, 1, 4: - // X - // - // or - // - // XX - // XX - // Square - return CGSize(width: maxMessageWidth, height: maxMessageWidth) - case 2: - // X X - // side-by-side. - let imageSize = (maxMessageWidth - kSpacingPts) / 2 - return CGSize(width: maxMessageWidth, height: imageSize) - case 3: - // x - // X x - // Big on left, 2 small on right. - let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 - let bigImageSize = smallImageSize * 2 + kSpacingPts - return CGSize(width: maxMessageWidth, height: bigImageSize) - default: - // X X - // xxx - // 2 big on top, 3 small on bottom. - let bigImageSize = (maxMessageWidth - kSpacingPts) / 2 - let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 - return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts) + case 0, 1, 4: + // X + // + // or + // + // XX + // XX + // Square + return CGSize(width: maxMessageWidth, height: maxMessageWidth) + + case 2: + // X X + // side-by-side. + let imageSize = (maxMessageWidth - kSpacingPts) / 2 + return CGSize(width: maxMessageWidth, height: imageSize) + + case 3: + // x + // X x + // Big on left, 2 small on right. + let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 + let bigImageSize = smallImageSize * 2 + kSpacingPts + return CGSize(width: maxMessageWidth, height: bigImageSize) + + default: + // X X + // xxx + // 2 big on top, 3 small on bottom. + let bigImageSize = (maxMessageWidth - kSpacingPts) / 2 + let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 + return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts) } } - @objc public func mediaView(forLocation location: CGPoint) -> MediaView? { var bestMediaView: MediaView? var bestDistance: CGFloat = 0 @@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView { return bestMediaView } - @objc public func isMoreItemsView(mediaView: MediaView) -> Bool { return moreItemsView == mediaView } diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index 5c4f586af..4f65a24d5 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -1,18 +1,18 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class MediaPlaceholderView : UIView { - private let viewItem: ConversationViewItem - private let textColor: UIColor - - // MARK: Settings +import UIKit +import SessionMessagingKit + +final class MediaPlaceholderView: UIView { private static let iconSize: CGFloat = 24 private static let iconImageViewSize: CGFloat = 40 - // MARK: Lifecycle - init(viewItem: ConversationViewItem, textColor: UIColor) { - self.viewItem = viewItem - self.textColor = textColor + // MARK: - Lifecycle + + init(item: ConversationViewModel.Item, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy(item: item, textColor: textColor) } override init(frame: CGRect) { @@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView { preconditionFailure("Use init(viewItem:textColor:) instead.") } - private func setUpViewHierarchy() { + private func setUpViewHierarchy( + item: ConversationViewModel.Item, + textColor: UIColor + ) { let (iconName, attachmentDescription): (String, String) = { - guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur - var attachments: [TSAttachment] = [] - Storage.read { transaction in - attachments = message.attachments(with: transaction) + guard + item.interactionVariant == .standardIncoming, + let attachment: Attachment = item.attachments?.first + else { + return ("actionsheet_document_black", "file") // Should never occur } - guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur - if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") } - if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") } + + if attachment.isAudio { return ("attachment_audio", "audio") } + if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") } + return ("actionsheet_document_black", "file") }() + // Image view - let iconSize = MediaPlaceholderView.iconSize - let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) - let imageView = UIImageView(image: icon) + let imageView = UIImageView( + image: UIImage(named: iconName)? + .withRenderingMode(.alwaysTemplate) + .resizedImage( + to: CGSize( + width: MediaPlaceholderView.iconSize, + height: MediaPlaceholderView.iconSize + ) + ) + ) + imageView.tintColor = textColor imageView.contentMode = .center - let iconImageViewSize = MediaPlaceholderView.iconImageViewSize - imageView.set(.width, to: iconImageViewSize) - imageView.set(.height, to: iconImageViewSize) + imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize) + imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize) + // Body label let titleLabel = UILabel() titleLabel.lineBreakMode = .byTruncatingTail titleLabel.text = "Tap to download \(attachmentDescription)" titleLabel.textColor = textColor titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) + // Stack view let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) stackView.axis = .horizontal diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 15ff3b413..72350d966 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -1,13 +1,13 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import YYImage import SessionUIKit +import SessionMessagingKit -@objc(OWSMediaView) public class MediaView: UIView { - + static let contentMode: UIView.ContentMode = .scaleAspectFill + private enum MediaError { case missing case invalid @@ -17,8 +17,7 @@ public class MediaView: UIView { // MARK: - private let mediaCache: NSCache - @objc - public let attachment: TSAttachment + public let attachment: Attachment private let isOutgoing: Bool private let maxMessageWidth: CGFloat private var loadBlock: (() -> Void)? @@ -42,50 +41,16 @@ public class MediaView: UIView { case failed } - // Thread-safe access to load state. - // - // We use a "box" class so that we can capture a reference - // to this box (rather than self) and a) safely access - // if off the main thread b) not prevent deallocation of - // self. - private class ThreadSafeLoadState { - private var value: LoadState - - required init(_ value: LoadState) { - self.value = value - } - - func get() -> LoadState { - objc_sync_enter(self) - let valueCopy = value - objc_sync_exit(self) - return valueCopy - } - - func set(_ newValue: LoadState) { - objc_sync_enter(self) - value = newValue - objc_sync_exit(self) - } - } - private let threadSafeLoadState = ThreadSafeLoadState(.unloaded) - // Convenience accessors. - private var loadState: LoadState { - get { - return threadSafeLoadState.get() - } - set { - threadSafeLoadState.set(newValue) - } - } + private let loadState: Atomic = Atomic(.unloaded) // MARK: - Initializers - @objc - public required init(mediaCache: NSCache, - attachment: TSAttachment, - isOutgoing: Bool, - maxMessageWidth: CGFloat) { + public required init( + mediaCache: NSCache, + attachment: Attachment, + isOutgoing: Bool, + maxMessageWidth: CGFloat + ) { self.mediaCache = mediaCache self.attachment = attachment self.isOutgoing = isOutgoing @@ -105,9 +70,7 @@ public class MediaView: UIView { } deinit { - AssertIsOnMainThread() - - loadState = .unloaded + loadState.mutate { $0 = .unloaded } } // MARK: - @@ -115,41 +78,41 @@ public class MediaView: UIView { private func createContents() { AssertIsOnMainThread() - guard let attachmentStream = attachment as? TSAttachmentStream else { + guard attachment.state == .uploaded || attachment.state == .downloaded else { addDownloadProgressIfNecessary() return } - guard !isFailedDownload else { + guard attachment.state != .failed else { configure(forError: .failed) return } - if attachmentStream.isAnimated { - configureForAnimatedImage(attachmentStream: attachmentStream) - } else if attachmentStream.isImage { - configureForStillImage(attachmentStream: attachmentStream) - } else if attachmentStream.isVideo { - configureForVideo(attachmentStream: attachmentStream) - } else { + + if attachment.isAnimated { + configureForAnimatedImage(attachment: attachment) + } + else if attachment.isImage { + configureForStillImage(attachment: attachment) + } + else if attachment.isVideo { + configureForVideo(attachment: attachment) + } + else { owsFailDebug("Attachment has unexpected type.") configure(forError: .invalid) } } private func addDownloadProgressIfNecessary() { - guard !isFailedDownload else { + guard attachment.state != .failed else { configure(forError: .failed) return } - guard let attachmentPointer = attachment as? TSAttachmentPointer else { - owsFailDebug("Attachment has unexpected type.") - configure(forError: .invalid) - return - } - guard attachmentPointer.pointerType == .incoming else { + guard attachment.state != .uploading && attachment.state != .uploaded else { // TODO: Show "restoring" indicator and possibly progress. configure(forError: .missing) return } + backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) let loader = MediaLoaderView() addSubview(loader) @@ -158,23 +121,20 @@ public class MediaView: UIView { private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { guard isOutgoing else { return false } - guard let attachmentStream = attachment as? TSAttachmentStream else { return false } - guard !attachmentStream.isUploaded else { return false } + guard attachment.state != .uploaded else { return false } + let loader = MediaLoaderView() addSubview(loader) loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) + return true } - private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) { - guard let cacheKey = attachmentStream.uniqueId else { - owsFailDebug("Attachment stream missing unique ID.") - return - } - let animatedImageView = YYAnimatedImageView() + private func configureForAnimatedImage(attachment: Attachment) { + let animatedImageView: YYAnimatedImageView = YYAnimatedImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. - animatedImageView.contentMode = .scaleAspectFill + animatedImageView.contentMode = MediaView.contentMode // Use trilinear filters for better scaling quality at // some performance cost. animatedImageView.layer.minificationFilter = .trilinear @@ -187,36 +147,37 @@ public class MediaView: UIView { loadBlock = { [weak self] in AssertIsOnMainThread() - guard let strongSelf = self else { - return - } + guard let strongSelf = self else { return } if animatedImageView.image != nil { owsFailDebug("Unexpectedly already loaded.") return } - strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in - guard attachmentStream.isValidImage else { - Logger.warn("Ignoring invalid attachment.") - return nil - } - guard let filePath = attachmentStream.originalFilePath else { - owsFailDebug("Attachment stream missing original file path.") - return nil - } - let animatedImage = YYImage(contentsOfFile: filePath) - return animatedImage - }, - applyMediaBlock: { (media) in - AssertIsOnMainThread() - - guard let image = media as? YYImage else { - owsFailDebug("Media has unexpected type: \(type(of: media))") - return - } - animatedImageView.image = image - }, - cacheKey: cacheKey) + strongSelf.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + guard attachment.isValid else { + Logger.warn("Ignoring invalid attachment.") + return + } + guard let filePath: String = attachment.originalFilePath else { + owsFailDebug("Attachment stream missing original file path.") + return + } + + applyMediaBlock(YYImage(contentsOfFile: filePath)) + }, + applyMediaBlock: { media in + AssertIsOnMainThread() + + guard let image: YYImage = media as? YYImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + return + } + + animatedImageView.image = image + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -225,15 +186,11 @@ public class MediaView: UIView { } } - private func configureForStillImage(attachmentStream: TSAttachmentStream) { - guard let cacheKey = attachmentStream.uniqueId else { - owsFailDebug("Attachment stream missing unique ID.") - return - } + private func configureForStillImage(attachment: Attachment) { let stillImageView = UIImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. - stillImageView.contentMode = .scaleAspectFill + stillImageView.contentMode = MediaView.contentMode // Use trilinear filters for better scaling quality at // some performance cost. stillImageView.layer.minificationFilter = .trilinear @@ -242,6 +199,7 @@ public class MediaView: UIView { addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(stillImageView) + loadBlock = { [weak self] in AssertIsOnMainThread() @@ -249,29 +207,31 @@ public class MediaView: UIView { owsFailDebug("Unexpectedly already loaded.") return } - self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in - guard attachmentStream.isValidImage else { - Logger.warn("Ignoring invalid attachment.") - return nil - } - return attachmentStream.thumbnailImageLarge(success: { (image) in + self?.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + guard attachment.isValid else { + Logger.warn("Ignoring invalid attachment.") + return + } + + attachment.thumbnail( + size: .large, + success: { image, _ in applyMediaBlock(image) }, + failure: { Logger.error("Could not load thumbnail") } + ) + }, + applyMediaBlock: { media in AssertIsOnMainThread() - + + guard let image: UIImage = media as? UIImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + return + } + stillImageView.image = image - }, failure: { - Logger.error("Could not load thumbnail") - }) - }, - applyMediaBlock: { (media) in - AssertIsOnMainThread() - - guard let image = media as? UIImage else { - owsFailDebug("Media has unexpected type: \(type(of: media))") - return - } - stillImageView.image = image - }, - cacheKey: cacheKey) + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -280,15 +240,11 @@ public class MediaView: UIView { } } - private func configureForVideo(attachmentStream: TSAttachmentStream) { - guard let cacheKey = attachmentStream.uniqueId else { - owsFailDebug("Attachment stream missing unique ID.") - return - } + private func configureForVideo(attachment: Attachment) { let stillImageView = UIImageView() // We need to specify a contentMode since the size of the image // might not match the aspect ratio of the view. - stillImageView.contentMode = .scaleAspectFill + stillImageView.contentMode = MediaView.contentMode // Use trilinear filters for better scaling quality at // some performance cost. stillImageView.layer.minificationFilter = .trilinear @@ -314,29 +270,31 @@ public class MediaView: UIView { owsFailDebug("Unexpectedly already loaded.") return } - self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in - guard attachmentStream.isValidVideo else { - Logger.warn("Ignoring invalid attachment.") - return nil - } - return attachmentStream.thumbnailImageMedium(success: { (image) in + self?.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + guard attachment.isValid else { + Logger.warn("Ignoring invalid attachment.") + return + } + + attachment.thumbnail( + size: .medium, + success: { image, _ in applyMediaBlock(image) }, + failure: { Logger.error("Could not load thumbnail") } + ) + }, + applyMediaBlock: { media in AssertIsOnMainThread() + guard let image: UIImage = media as? UIImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + return + } + stillImageView.image = image - }, failure: { - Logger.error("Could not load thumbnail") - }) - }, - applyMediaBlock: { (media) in - AssertIsOnMainThread() - - guard let image = media as? UIImage else { - owsFailDebug("Media has unexpected type: \(type(of: media))") - return - } - stillImageView.image = image - }, - cacheKey: cacheKey) + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -345,101 +303,89 @@ public class MediaView: UIView { } } - private var isFailedDownload: Bool { - guard let attachmentPointer = attachment as? TSAttachmentPointer else { - return false - } - return attachmentPointer.state == .failed - } - private func configure(forError error: MediaError) { - backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) let icon: UIImage + switch error { - case .failed: - guard let asset = UIImage(named: "media_retry") else { - owsFailDebug("Missing image") - return - } - icon = asset - case .invalid: - guard let asset = UIImage(named: "media_invalid") else { - owsFailDebug("Missing image") - return - } - icon = asset - case .missing: - return + case .failed: + guard let asset = UIImage(named: "media_retry") else { + owsFailDebug("Missing image") + return + } + icon = asset + + case .invalid: + guard let asset = UIImage(named: "media_invalid") else { + owsFailDebug("Missing image") + return + } + icon = asset + + case .missing: return } + + backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) + let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) addSubview(iconView) iconView.autoCenterInSuperview() } - private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?, - applyMediaBlock: @escaping (AnyObject) -> Void, - cacheKey: String) { - AssertIsOnMainThread() - + private func tryToLoadMedia( + loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void, + applyMediaBlock: @escaping (AnyObject) -> Void, + cacheKey: String + ) { // It's critical that we update loadState once // our load attempt is complete. - let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in - AssertIsOnMainThread() - - guard let strongSelf = self else { - return - } - guard strongSelf.loadState == .loading else { + let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in + guard self?.loadState.wrappedValue == .loading else { Logger.verbose("Skipping obsolete load.") return } - guard let media = possibleMedia else { - strongSelf.loadState = .failed + guard let media: AnyObject = possibleMedia else { + self?.loadState.mutate { $0 = .failed } // TODO: // [self showAttachmentErrorViewWithMediaView:mediaView]; return } - + applyMediaBlock(media) - - strongSelf.loadState = .loaded + + self?.mediaCache.setObject(media, forKey: cacheKey as NSString) + self?.loadState.mutate { $0 = .loaded } } - guard loadState == .loading else { + guard loadState.wrappedValue == .loading else { owsFailDebug("Unexpected load state: \(loadState)") return } - let mediaCache = self.mediaCache - if let media = mediaCache.object(forKey: cacheKey as NSString) { + if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) { Logger.verbose("media cache hit") + + guard !Thread.isMainThread else { + DispatchQueue.main.async { + loadMediaBlock(loadCompletion) + } + return + } + loadCompletion(media) return } Logger.verbose("media cache miss") - let threadSafeLoadState = self.threadSafeLoadState - MediaView.loadQueue.async { - guard threadSafeLoadState.get() == .loading else { + MediaView.loadQueue.async { [weak self] in + guard self?.loadState.wrappedValue == .loading else { Logger.verbose("Skipping obsolete load.") return } - guard let media = loadMediaBlock() else { - Logger.error("Failed to load media.") - - DispatchQueue.main.async { - loadCompletion(nil) - } - return - } - DispatchQueue.main.async { - mediaCache.setObject(media, forKey: cacheKey as NSString) - - loadCompletion(media) + loadMediaBlock(loadCompletion) } } } @@ -459,32 +405,18 @@ public class MediaView: UIView { // "skip rate" of obsolete loads. private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue") - @objc public func loadMedia() { - AssertIsOnMainThread() - - switch loadState { - case .unloaded: - loadState = .loading - - guard let loadBlock = loadBlock else { - return - } - loadBlock() - case .loading, .loaded, .failed: - break + switch loadState.wrappedValue { + case .unloaded: + loadState.mutate { $0 = .loading } + loadBlock?() + + case .loading, .loaded, .failed: break } } - @objc public func unloadMedia() { - AssertIsOnMainThread() - - loadState = .unloaded - - guard let unloadBlock = unloadBlock else { - return - } - unloadBlock() + loadState.mutate { $0 = .unloaded } + unloadBlock?() } } diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index bfaf22592..4338ebfa8 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -135,7 +135,7 @@ final class QuoteView: UIView { attachment.thumbnail( size: .small, - success: { image in + success: { image, _ in DispatchQueue.main.async { imageView.image = image imageView.contentMode = .scaleAspectFill diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index f1933ddef..7ea44dc82 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1,10 +1,19 @@ -import SessionUtilitiesKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { +import UIKit +import SignalUtilitiesKit +import SessionUtilitiesKit +import SessionMessagingKit + +final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDelegate { private var unloadContent: (() -> Void)? private var previousX: CGFloat = 0 + var albumView: MediaAlbumView? var bodyTextView: UITextView? + var voiceMessageView: VoiceMessageView? + var audioStateChanged: ((TimeInterval, Bool) -> ())? + // Constraints private lazy var headerViewTopConstraint = headerView.pin(.top, to: .top, of: self, withInset: 1) private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) @@ -29,59 +38,37 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { var lastSearchedText: String? { delegate?.lastSearchedText } - private var positionInCluster: Position? { - guard let viewItem = viewItem else { return nil } - if viewItem.isFirstInCluster { return .top } - if viewItem.isLastInCluster { return .bottom } - return .middle - } + // MARK: - UI Components - private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true } - - private var direction: Direction { - guard let message = viewItem?.interaction as? TSMessage else { preconditionFailure() } - switch message { - case is TSIncomingMessage: return .incoming - case is TSOutgoingMessage: return .outgoing - default: preconditionFailure() - } - } - - private var shouldInsetHeader: Bool { - guard let viewItem = viewItem else { preconditionFailure() } - return (positionInCluster == .top || isOnlyMessageInCluster) && !viewItem.wasPreviousItemInfoMessage - } - - // MARK: UI Components private lazy var profilePictureView: ProfilePictureView = { - let result = ProfilePictureView() - let size = Values.verySmallProfilePictureSize - result.set(.height, to: size) - result.size = size + let result: ProfilePictureView = ProfilePictureView() + result.set(.height, to: Values.verySmallProfilePictureSize) + result.size = Values.verySmallProfilePictureSize + return result }() - + private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) - + lazy var bubbleView: UIView = { let result = UIView() result.layer.cornerRadius = VisibleMessageCell.largeCornerRadius result.set(.width, greaterThanOrEqualTo: VisibleMessageCell.largeCornerRadius * 2) return result }() - + private let bubbleViewMaskLayer = CAShapeLayer() - + private lazy var headerView = UIView() - + private lazy var authorLabel: UILabel = { let result = UILabel() result.font = .boldSystemFont(ofSize: Values.smallFontSize) return result }() - + private lazy var snContentView = UIView() - + internal lazy var messageStatusImageView: UIImageView = { let result = UIImageView() result.contentMode = .scaleAspectFit @@ -89,7 +76,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.layer.masksToBounds = true return result }() - + private lazy var replyButton: UIView = { let result = UIView() let size = VisibleMessageCell.replyButtonSize + 8 @@ -102,7 +89,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.alpha = 0 return result }() - + private lazy var replyIconImageView: UIImageView = { let result = UIImageView() let size = VisibleMessageCell.replyButtonSize @@ -111,10 +98,11 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.image = UIImage(named: "ic_reply")!.withTint(Colors.text) return result }() + + private lazy var timerView: OWSMessageTimerView = OWSMessageTimerView() + + // MARK: - Settings - private lazy var timerView = OWSMessageTimerView() - - // MARK: Settings private static let messageStatusImageViewSize: CGFloat = 16 private static let authorLabelBottomSpacing: CGFloat = 4 private static let groupThreadHSpacing: CGFloat = 12 @@ -126,57 +114,56 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { static let smallCornerRadius: CGFloat = 4 static let largeCornerRadius: CGFloat = 18 static let contactThreadHSpacing = Values.mediumSpacing - + static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing } - - private var bodyLabelTextColor: UIColor { - switch (direction, AppModeManager.shared.currentAppMode) { - case (.outgoing, .dark), (.incoming, .light): return .black - case (.outgoing, .light): return Colors.grey - default: return .white - } - } - - override class var identifier: String { "VisibleMessageCell" } - + // MARK: Direction & Position - enum Direction { case incoming, outgoing } - enum Position { case top, middle, bottom } - // MARK: Lifecycle + enum Direction { case incoming, outgoing } + + // MARK: - Lifecycle + override func setUpViewHierarchy() { super.setUpViewHierarchy() + // Header view addSubview(headerView) headerViewTopConstraint.isActive = true headerView.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) + // Author label addSubview(authorLabel) authorLabelHeightConstraint.isActive = true authorLabel.pin(.top, to: .bottom, of: headerView) + // Profile picture view addSubview(profilePictureView) profilePictureViewLeftConstraint.isActive = true profilePictureViewWidthConstraint.isActive = true profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -1) + // Moderator icon image view moderatorIconImageView.set(.width, to: 20) moderatorIconImageView.set(.height, to: 20) addSubview(moderatorIconImageView) moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Bubble view addSubview(bubbleView) bubbleViewLeftConstraint1.isActive = true bubbleViewTopConstraint.isActive = true bubbleViewRightConstraint1.isActive = true + // Timer view addSubview(timerView) timerView.center(.vertical, in: bubbleView) timerViewOutgoingMessageConstraint.isActive = true + // Content view bubbleView.addSubview(snContentView) snContentView.pin(to: bubbleView) + // Message status image view addSubview(messageStatusImageView) messageStatusImageViewTopConstraint.isActive = true @@ -184,286 +171,425 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1) messageStatusImageViewWidthConstraint.isActive = true messageStatusImageViewHeightConstraint.isActive = true + // Reply button addSubview(replyButton) replyButton.addSubview(replyIconImageView) replyIconImageView.center(in: replyButton) replyButton.pin(.left, to: .right, of: bubbleView, withInset: Values.smallSpacing) replyButton.center(.vertical, in: bubbleView) + // Remaining constraints authorLabel.pin(.left, to: .left, of: bubbleView, withInset: VisibleMessageCell.authorLabelInset) } - + override func setUpGestureRecognizers() { let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) addGestureRecognizer(longPressRecognizer) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapGestureRecognizer.numberOfTapsRequired = 1 addGestureRecognizer(tapGestureRecognizer) + let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) doubleTapGestureRecognizer.numberOfTapsRequired = 2 addGestureRecognizer(doubleTapGestureRecognizer) tapGestureRecognizer.require(toFail: doubleTapGestureRecognizer) } + + // MARK: - Updating - // MARK: Updating - override func update() { - guard let viewItem = viewItem, let message = viewItem.interaction as? TSMessage else { return } - let isGroupThread = viewItem.isGroupThread + override func update( + with item: ConversationViewModel.Item, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + self.item = item + + let isGroupThread: Bool = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let shouldInsetHeader: Bool = ( + item.previousInteractionVariant?.isInfoMessage != true && + ( + item.positionInCluster == .top || + item.isOnlyMessageInCluster + ) + ) + // Profile picture view - profilePictureViewLeftConstraint.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0 - profilePictureViewWidthConstraint.constant = isGroupThread ? VisibleMessageCell.profilePictureSize : 0 - let senderSessionID = (message as? TSIncomingMessage)?.authorId - profilePictureView.isHidden = !VisibleMessageCell.shouldShowProfilePicture(for: viewItem) - if let senderSessionID = senderSessionID { - profilePictureView.update(for: senderSessionID) - } - if let senderSessionID = senderSessionID, message.isOpenGroupMessage { - if let openGroupV2 = Storage.shared.getV2OpenGroup(for: message.uniqueThreadId) { - let isUserModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, for: openGroupV2.room, on: openGroupV2.server) - moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden - } else { - moderatorIconImageView.isHidden = true - } - } else { - moderatorIconImageView.isHidden = true - } + profilePictureViewLeftConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) + profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0) + profilePictureView.isHidden = (!item.shouldShowProfile || item.profile == nil) + profilePictureView.update( + publicKey: item.authorId, + profile: item.profile, + threadVariant: item.threadVariant + ) + moderatorIconImageView.isHidden = !item.isSenderOpenGroupModerator + // Bubble view - bubbleViewLeftConstraint1.isActive = (direction == .incoming) - bubbleViewLeftConstraint1.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing - bubbleViewLeftConstraint2.isActive = (direction == .outgoing) - bubbleViewTopConstraint.constant = (viewItem.senderName == nil) ? 0 : VisibleMessageCell.authorLabelBottomSpacing - bubbleViewRightConstraint1.isActive = (direction == .outgoing) - bubbleViewRightConstraint2.isActive = (direction == .incoming) - bubbleView.backgroundColor = (direction == .incoming) ? Colors.receivedMessageBackground : Colors.sentMessageBackground + bubbleViewLeftConstraint1.isActive = ( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) + bubbleViewLeftConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) + bubbleViewLeftConstraint2.isActive = (item.interactionVariant == .standardOutgoing) + bubbleViewTopConstraint.constant = (item.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) + bubbleViewRightConstraint1.isActive = (item.interactionVariant == .standardOutgoing) + bubbleViewRightConstraint2.isActive = ( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) + bubbleView.backgroundColor = (( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) ? Colors.receivedMessageBackground : Colors.sentMessageBackground) updateBubbleViewCorners() + // Content view - populateContentView(for: viewItem, message: message) + populateContentView(for: item, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText) + // Date break - headerViewTopConstraint.constant = shouldInsetHeader ? Values.mediumSpacing : 1 + headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1) headerView.subviews.forEach { $0.removeFromSuperview() } - if viewItem.shouldShowDate { - populateHeader(for: viewItem) - } + populateHeader(for: item, shouldInsetHeader: shouldInsetHeader) + // Author label authorLabel.textColor = Colors.text - authorLabel.isHidden = (viewItem.senderName == nil) - authorLabel.text = viewItem.senderName?.string // Will only be set if it should be shown - let authorLabelAvailableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * VisibleMessageCell.authorLabelInset + authorLabel.isHidden = (item.senderName == nil) + authorLabel.text = item.senderName + + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * VisibleMessageCell.authorLabelInset) let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) - authorLabelHeightConstraint.constant = (viewItem.senderName != nil) ? authorLabelSize.height : 0 + authorLabelHeightConstraint.constant = (item.senderName != nil ? authorLabelSize.height : 0) + // Message status image view - let (image, tintColor, backgroundColor) = getMessageStatusImage(for: message) + let (image, tintColor, backgroundColor) = getMessageStatusImage(for: item) messageStatusImageView.image = image messageStatusImageView.tintColor = tintColor messageStatusImageView.backgroundColor = backgroundColor - if let message = message as? TSOutgoingMessage { - messageStatusImageView.isHidden = (message.messageState == .sent && thread?.lastInteraction != message) - } else { - messageStatusImageView.isHidden = true - } - messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden) ? 0 : 5 - [ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ].forEach { - $0.constant = (messageStatusImageView.isHidden) ? 0 : VisibleMessageCell.messageStatusImageViewSize - } + messageStatusImageView.isHidden = ( + item.interactionVariant != .standardOutgoing || ( + item.state == .sent && + item.isLastInteraction + ) + ) + messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5) + [ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ] + .forEach { + $0.constant = (messageStatusImageView.isHidden ? 0 : VisibleMessageCell.messageStatusImageViewSize) + } + // Timer - if viewItem.isExpiringMessage { - let expirationTimestamp = message.expiresAt - let expiresInSeconds = message.expiresInSeconds - timerView.configure(withExpirationTimestamp: expirationTimestamp, initialDurationSeconds: expiresInSeconds, tintColor: Colors.text) + if + item.isExpiringMessage, + let expiresStartedAtMs: Double = item.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = item.expiresInSeconds + { + let expirationTimestampMs: Double = (expiresStartedAtMs + (expiresInSeconds * 1000)) + + timerView.configure( + withExpirationTimestamp: UInt64(floor(expirationTimestampMs)), + initialDurationSeconds: UInt32(floor(expiresInSeconds)), + tintColor: Colors.text + ) } - timerView.isHidden = !viewItem.isExpiringMessage - timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing) - timerViewIncomingMessageConstraint.isActive = (direction == .incoming) + + timerView.isHidden = !item.isExpiringMessage + timerViewOutgoingMessageConstraint.isActive = (item.interactionVariant == .standardOutgoing) + timerViewIncomingMessageConstraint.isActive = ( + item.interactionVariant == .standardIncoming || + item.interactionVariant == .standardIncomingDeleted + ) + // Swipe to reply - if (message.isDeleted) { + if item.interactionVariant == .standardIncomingDeleted { removeGestureRecognizer(panGestureRecognizer) - } else { + } + else { addGestureRecognizer(panGestureRecognizer) } } - - private func populateHeader(for viewItem: ConversationViewItem) { - guard viewItem.shouldShowDate else { return } - let dateBreakLabel = UILabel() + + private func populateHeader(for item: ConversationViewModel.Item, shouldInsetHeader: Bool) { + guard let date: Date = item.dateForUI else { return } + + let dateBreakLabel: UILabel = UILabel() dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) dateBreakLabel.textColor = Colors.text dateBreakLabel.textAlignment = .center - let date = viewItem.interaction.dateForUI() - let description = DateUtil.formatDate(forDisplay: date) + + let description: String = DateUtil.formatDate(forDisplay: date) dateBreakLabel.text = description headerView.addSubview(dateBreakLabel) dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing) - let additionalBottomInset = shouldInsetHeader ? Values.mediumSpacing : 1 + + let additionalBottomInset = (shouldInsetHeader ? Values.mediumSpacing : 1) headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset) dateBreakLabel.center(.horizontal, in: headerView) - let availableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) + + let availableWidth = VisibleMessageCell.getMaxWidth(for: item) let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace) dateBreakLabel.set(.height, to: dateBreakLabelSize.height) } - - private func populateContentView(for viewItem: ConversationViewItem, message: TSMessage) { + + private func populateContentView( + for item: ConversationViewModel.Item, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + let bodyLabelTextColor: UIColor = { + let direction: Direction = (item.interactionVariant == .standardOutgoing ? + .outgoing : + .incoming + ) + + switch (direction, AppModeManager.shared.currentAppMode) { + case (.outgoing, .dark), (.incoming, .light): return .black + case (.outgoing, .light): return Colors.grey + default: return .white + } + }() + snContentView.subviews.forEach { $0.removeFromSuperview() } - func showMediaPlaceholder() { - let mediaPlaceholderView = MediaPlaceholderView(viewItem: viewItem, textColor: bodyLabelTextColor) - snContentView.addSubview(mediaPlaceholderView) - mediaPlaceholderView.pin(to: snContentView) - } albumView = nil bodyTextView = nil - let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage) - switch viewItem.messageCellType { - case .textOnlyMessage: - let inset: CGFloat = 12 - let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset - if let linkPreview = viewItem.linkPreview { - let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self) - linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment) - snContentView.addSubview(linkPreviewView) - linkPreviewView.pin(to: snContentView) - linkPreviewView.layer.mask = bubbleViewMaskLayer - self.bodyTextView = linkPreviewView.bodyTextView - } else if let openGroupInvitationName = message.openGroupInvitationName, let openGroupInvitationURL = message.openGroupInvitationURL { - let openGroupInvitationView = OpenGroupInvitationView(name: openGroupInvitationName, url: openGroupInvitationURL, textColor: bodyLabelTextColor, isOutgoing: isOutgoing) - snContentView.addSubview(openGroupInvitationView) - openGroupInvitationView.pin(to: snContentView) - openGroupInvitationView.layer.mask = bubbleViewMaskLayer - } else { - // Stack view - let stackView = UIStackView(arrangedSubviews: []) - stackView.axis = .vertical - stackView.spacing = 2 - // Quote view - if viewItem.quotedReply != nil { - let direction: QuoteView.Direction = isOutgoing ? .outgoing : .incoming - let hInset: CGFloat = 2 - let quoteView = QuoteView(for: viewItem, in: thread, direction: direction, hInset: hInset, maxWidth: maxWidth) - let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) - stackView.addArrangedSubview(quoteViewContainer) + + // Handle the deleted state first (it's much simpler than the others) + guard item.interactionVariant != .standardIncomingDeleted else { + let deletedMessageView: DeletedMessageView = DeletedMessageView(textColor: bodyLabelTextColor) + snContentView.addSubview(deletedMessageView) + deletedMessageView.pin(to: snContentView) + return + } + + // If it's an incoming media message and the thread isn't trusted then show the placeholder view + if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + let mediaPlaceholderView = MediaPlaceholderView(item: item, textColor: bodyLabelTextColor) + snContentView.addSubview(mediaPlaceholderView) + mediaPlaceholderView.pin(to: snContentView) + return + } + + switch item.cellType { + case .typingIndicator: break + + case .textOnlyMessage: + let inset: CGFloat = 12 + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + + if let linkPreview: LinkPreview = item.linkPreview { + switch linkPreview.variant { + case .standard: + let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) + linkPreviewView.update( + with: LinkPreviewSent( + linkPreview: linkPreview, + imageAttachment: item.attachments?.first + ), + isOutgoing: (item.interactionVariant == .standardOutgoing), + delegate: self, + item: item, + bodyLabelTextColor: bodyLabelTextColor, + lastSearchText: lastSearchText + ) + snContentView.addSubview(linkPreviewView) + linkPreviewView.pin(to: snContentView) + linkPreviewView.layer.mask = bubbleViewMaskLayer + self.bodyTextView = linkPreviewView.bodyTextView + + case .openGroupInvitation: + let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( + name: (linkPreview.title ?? ""), + url: linkPreview.url, + textColor: bodyLabelTextColor, + isOutgoing: (item.interactionVariant == .standardOutgoing) + ) + + snContentView.addSubview(openGroupInvitationView) + openGroupInvitationView.pin(to: snContentView) + openGroupInvitationView.layer.mask = bubbleViewMaskLayer + } } - // Body text view - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate?.lastSearchedText, delegate: self) - self.bodyTextView = bodyTextView - stackView.addArrangedSubview(bodyTextView) - // Constraints - snContentView.addSubview(stackView) - stackView.pin(to: snContentView, withInset: inset) - } - case .mediaMessage: - if - viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - showMediaPlaceholder() - } - else { - guard let cache = delegate?.getMediaCache() else { preconditionFailure() } + else { + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + + // Quote view + if let quote: Quote = item.quote { + let hInset: CGFloat = 2 + let quoteView: QuoteView = QuoteView( + for: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: item.threadVariant, + direction: (item.interactionVariant == .standardOutgoing ? + .outgoing : + .incoming + ), + attachment: item.attachments?.first, + hInset: hInset, + maxWidth: maxWidth + ) + let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) + stackView.addArrangedSubview(quoteViewContainer) + } + + // Body text view + let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) + self.bodyTextView = bodyTextView + stackView.addArrangedSubview(bodyTextView) + + // Constraints + snContentView.addSubview(stackView) + stackView.pin(to: snContentView, withInset: inset) + } + + case .mediaMessage: // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical stackView.spacing = Values.smallSpacing + // Album view - let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth) + let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: item) + let albumView = MediaAlbumView( + mediaCache: mediaCache, + items: (item.attachments? + .filter { $0.isVisualMedia }) + .defaulting(to: []), + isOutgoing: (item.interactionVariant == .standardOutgoing), + maxMessageWidth: maxMessageWidth + ) self.albumView = albumView - let size = getSize(for: viewItem) + let size = getSize(for: item) albumView.set(.width, to: size.width) albumView.set(.height, to: size.height) albumView.loadMedia() albumView.layer.mask = bubbleViewMaskLayer stackView.addArrangedSubview(albumView) + // Body text view - if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0 { + if let body: String = item.body, !body.isEmpty { let inset: CGFloat = 12 let maxWidth = size.width - 2 * inset - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate?.lastSearchedText, delegate: self) + let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) self.bodyTextView = bodyTextView stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset))) } unloadContent = { albumView.unloadMedia() } + // Constraints snContentView.addSubview(stackView) stackView.pin(to: snContentView) - } - case .audio: - if - viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - showMediaPlaceholder() - } - else { - let voiceMessageView = VoiceMessageView(viewItem: viewItem) + + case .audio: + guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + + let voiceMessageView: VoiceMessageView = VoiceMessageView() + voiceMessageView.update( + with: attachment, + isPlaying: (playbackInfo?.state == .playing), + progress: (playbackInfo?.progress ?? 0), + playbackRate: (playbackInfo?.playbackRate ?? 1), + oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1) + ) + snContentView.addSubview(voiceMessageView) voiceMessageView.pin(to: snContentView) voiceMessageView.layer.mask = bubbleViewMaskLayer - viewItem.lastAudioMessageView = voiceMessageView - } - case .genericAttachment: - if - viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }), - contact?.isTrusted != true { - showMediaPlaceholder() - } - else { + self.voiceMessageView = voiceMessageView + + case .genericAttachment: + guard let attachment: Attachment = item.attachments?.first else { preconditionFailure() } + let inset: CGFloat = 12 - let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset + let maxWidth = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset) + // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical stackView.spacing = Values.smallSpacing + // Document view - let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor) + let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor) stackView.addArrangedSubview(documentView) + // Body text view - if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0, - let delegate = delegate { // delegate should always be set at this point - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate.lastSearchedText, delegate: self) + if let body: String = item.body, !body.isEmpty { // delegate should always be set at this point + let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self) self.bodyTextView = bodyTextView stackView.addArrangedSubview(bodyTextView) } + // Constraints snContentView.addSubview(stackView) stackView.pin(to: snContentView, withInset: inset) - } - case .deletedMessage: - let deletedMessageView = DeletedMessageView(viewItem: viewItem, textColor: bodyLabelTextColor) - snContentView.addSubview(deletedMessageView) - deletedMessageView.pin(to: snContentView) - default: return } } - + override func layoutSubviews() { super.layoutSubviews() updateBubbleViewCorners() } - + private func updateBubbleViewCorners() { - let cornersToRound = getCornersToRound() - let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: cornersToRound, - cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) + let cornersToRound: UIRectCorner = getCornersToRound() + let maskPath: UIBezierPath = UIBezierPath( + roundedRect: bubbleView.bounds, + byRoundingCorners: cornersToRound, + cornerRadii: CGSize( + width: VisibleMessageCell.largeCornerRadius, + height: VisibleMessageCell.largeCornerRadius + ) + ) + bubbleViewMaskLayer.path = maskPath.cgPath bubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } + override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) { + guard item.interactionVariant != .standardIncomingDeleted else { return } + + // If it's an incoming media message and the thread isn't trusted then show the placeholder view + if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted { + return + } + + switch item.cellType { + case .audio: + guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return } + + self.voiceMessageView?.update( + with: attachment, + isPlaying: (playbackInfo?.state == .playing), + progress: (playbackInfo?.progress ?? 0), + playbackRate: (playbackInfo?.playbackRate ?? 1), + oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1) + ) + + default: break + } + } + override func prepareForReuse() { super.prepareForReuse() + unloadContent?() let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] viewsToMove.forEach { $0.transform = .identity } replyButton.alpha = 0 timerView.prepareForReuse() } + + // MARK: - Interaction - // MARK: Interaction override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let bodyTextView = bodyTextView { let pointInBodyTextViewCoordinates = convert(point, to: bodyTextView) @@ -473,99 +599,120 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { } return super.hitTest(point, with: event) } - + override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true // Needed for the pan gesture recognizer to work with the table view's pan gesture recognizer } - + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer == panGestureRecognizer { let v = panGestureRecognizer.velocity(in: self) // Only allow swipes to the left; allowing swipes to the right gets in the way of the default // iOS swipe to go back gesture guard v.x < 0 else { return false } - return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical - } else { - return true + return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical } + + return true } - + func highlight() { - let shawdowColour = isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor - let opacity : Float = isLightMode ? 0.5 : 1 + // FIXME: This will have issues with themes + let shawdowColour = (isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor) + let opacity: Float = (isLightMode ? 0.5 : 1) bubbleView.setShadow(radius: 10, opacity: opacity, offset: .zero, color: shawdowColour) + DispatchQueue.main.async { UIView.animate(withDuration: 1.6) { self.bubbleView.setShadow(radius: 0, opacity: 0, offset: .zero, color: UIColor.clear.cgColor) } } } - + @objc func handleLongPress() { - guard let viewItem = viewItem else { return } - delegate?.handleViewItemLongPressed(viewItem) + guard let item: ConversationViewModel.Item = self.item else { return } + + delegate?.handleItemLongPressed(item) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let viewItem = viewItem else { return } + guard let item: ConversationViewModel.Item = self.item else { return } + let location = gestureRecognizer.location(in: self) - if profilePictureView.frame.contains(location) && VisibleMessageCell.shouldShowProfilePicture(for: viewItem) { - guard let message = viewItem.interaction as? TSIncomingMessage else { return } - guard !message.isOpenGroupMessage else { return } // Do not show user details to prevent spam - delegate?.showUserDetails(for: message.authorId) - } else if replyButton.frame.contains(location) { + + if profilePictureView.frame.contains(location), let profile: Profile = item.profile, item.threadVariant != .openGroup { + delegate?.showUserDetails(for: profile) + } + else if replyButton.alpha > 0 && replyButton.frame.contains(location) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() reply() - } else { - delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) + } + else if bubbleView.frame.contains(location) { + delegate?.handleItemTapped(item, gestureRecognizer: gestureRecognizer) } } @objc private func handleDoubleTap() { - guard let viewItem = viewItem else { return } - delegate?.handleViewItemDoubleTapped(viewItem) + guard let item: ConversationViewModel.Item = self.item else { return } + + delegate?.handleItemDoubleTapped(item) } - + @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let viewItem = viewItem else { return } - let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] + guard let item: ConversationViewModel.Item = self.item else { return } + + let viewsToMove: [UIView] = [ + bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView + ] let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) + switch gestureRecognizer.state { - case .began: - delegate?.handleViewItemSwiped(viewItem, state: .began) - case .changed: - // The idea here is to asymptotically approach a maximum drag distance - let damping: CGFloat = 20 - let sign: CGFloat = -1 - let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign - viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } - if timerView.isHidden { - replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX - } else { - replyButton.alpha = 0 // Always hide the reply button if the timer view is showing, otherwise they can overlap - } - if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold - } - previousX = translationX - case .ended, .cancelled: - if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { - delegate?.handleViewItemSwiped(viewItem, state: .ended) - reply() - } else { - delegate?.handleViewItemSwiped(viewItem, state: .cancelled) - resetReply() - } - default: break + case .began: delegate?.handleItemSwiped(item, state: .began) + + case .changed: + // The idea here is to asymptotically approach a maximum drag distance + let damping: CGFloat = 20 + let sign: CGFloat = -1 + let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign + viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } + if timerView.isHidden { + replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX + } else { + replyButton.alpha = 0 // Always hide the reply button if the timer view is showing, otherwise they can overlap + } + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() // Let the user know when they've hit the swipe to reply threshold + } + previousX = translationX + + case .ended, .cancelled: + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { + delegate?.handleItemSwiped(item, state: .ended) + reply() + } + else { + delegate?.handleItemSwiped(item, state: .cancelled) + resetReply() + } + + default: break } } - - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - delegate?.openURL(URL) + + func textView(_ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + delegate?.openUrl(url.absoluteString) return false } + func textViewDidChangeSelection(_ textView: UITextView) { + // Note: We can't just set 'isSelectable' to false otherwise the link detection/selection + // stops working (do a null check to avoid an infinite loop on older iOS versions) + if textView.selectedTextRange != nil { + textView.selectedTextRange = nil + } + } + private func resetReply() { let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] UIView.animate(withDuration: 0.25) { @@ -573,47 +720,46 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { self.replyButton.alpha = 0 } } - + private func reply() { - guard let viewItem = viewItem else { return } + guard let item: ConversationViewModel.Item = self.item else { return } + resetReply() - delegate?.handleReplyButtonTapped(for: viewItem) + delegate?.handleReplyButtonTapped(for: item) } - func handleLinkPreviewCanceled() { - // Not relevant in this case - } + // MARK: - Convenience - // MARK: Convenience private func getCornersToRound() -> UIRectCorner { - guard !isOnlyMessageInCluster else { return .allCorners } - let result: UIRectCorner - switch (positionInCluster, direction) { - case (.top, .outgoing): result = [ .bottomLeft, .topLeft, .topRight ] - case (.middle, .outgoing): result = [ .bottomLeft, .topLeft ] - case (.bottom, .outgoing): result = [ .bottomRight, .bottomLeft, .topLeft ] - case (.top, .incoming): result = [ .topLeft, .topRight, .bottomRight ] - case (.middle, .incoming): result = [ .topRight, .bottomRight ] - case (.bottom, .incoming): result = [ .topRight, .bottomRight, .bottomLeft ] - case (nil, _): result = .allCorners + guard item?.isOnlyMessageInCluster == false else { return .allCorners } + + let direction: Direction = (item?.interactionVariant == .standardOutgoing ? .outgoing : .incoming) + + switch (item?.positionInCluster, direction) { + case (.top, .outgoing): return [ .bottomLeft, .topLeft, .topRight ] + case (.middle, .outgoing): return [ .bottomLeft, .topLeft ] + case (.bottom, .outgoing): return [ .bottomRight, .bottomLeft, .topLeft ] + case (.top, .incoming): return [ .topLeft, .topRight, .bottomRight ] + case (.middle, .incoming): return [ .topRight, .bottomRight ] + case (.bottom, .incoming): return [ .topRight, .bottomRight, .bottomLeft ] + case (.none, _): return .allCorners } - return result } private func getCornerMask(from rectCorner: UIRectCorner) -> CACornerMask { - var cornerMask = CACornerMask() - if rectCorner.contains(.allCorners) { - cornerMask = [ .layerMaxXMinYCorner, .layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner] - } else { - if rectCorner.contains(.topRight) { cornerMask.insert(.layerMaxXMinYCorner) } - if rectCorner.contains(.topLeft) { cornerMask.insert(.layerMinXMinYCorner) } - if rectCorner.contains(.bottomRight) { cornerMask.insert(.layerMaxXMaxYCorner) } - if rectCorner.contains(.bottomLeft) { cornerMask.insert(.layerMinXMaxYCorner) } + guard !rectCorner.contains(.allCorners) else { + return [ .layerMaxXMinYCorner, .layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner] } + + var cornerMask = CACornerMask() + if rectCorner.contains(.topRight) { cornerMask.insert(.layerMaxXMinYCorner) } + if rectCorner.contains(.topLeft) { cornerMask.insert(.layerMinXMinYCorner) } + if rectCorner.contains(.bottomRight) { cornerMask.insert(.layerMaxXMaxYCorner) } + if rectCorner.contains(.bottomLeft) { cornerMask.insert(.layerMinXMaxYCorner) } return cornerMask } - - private static func getFontSize(for viewItem: ConversationViewItem) -> CGFloat { + + private static func getFontSize(for item: ConversationViewModel.Item) -> CGFloat { let baselineFontSize = Values.mediumFontSize switch viewItem.displayableBodyText?.jumbomojiCount { case 1: return baselineFontSize + 30 @@ -622,104 +768,125 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { default: return baselineFontSize } } - - private func getMessageStatusImage(for message: TSMessage) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { - guard let message = message as? TSOutgoingMessage else { return (nil, nil, nil) } - + + private func getMessageStatusImage(for item: ConversationViewModel.Item) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { + guard item.interactionVariant == .standardOutgoing else { return (nil, nil, nil) } + let image: UIImage var tintColor: UIColor? = nil var backgroundColor: UIColor? = nil - let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: message) - switch status { - case .uploading, .sending: + switch (item.state, item.hasAtLeastOneReadReceipt) { + case (.sending, _): image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) tintColor = Colors.text - - case .sent, .skipped, .delivered: + + case (.sent, false), (.skipped, _): image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) tintColor = Colors.text - case .read: + case (.sent, true): image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode") backgroundColor = isLightMode ? .black : .white - case .failed: + case (.failed, _): image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) tintColor = Colors.destructive } - + return (image, tintColor, backgroundColor) } - - private func getSize(for viewItem: ConversationViewItem) -> CGSize { - guard let albumItems = viewItem.mediaAlbumItems else { preconditionFailure() } - let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: albumItems) - guard albumItems.count == 1 else { return defaultSize } + + private func getSize(for item: ConversationViewModel.Item) -> CGSize { + guard let mediaAttachments: [Attachment] = item.attachments?.filter({ $0.isVisualMedia }) else { preconditionFailure() } + + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: item) + let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: mediaAttachments) + + guard + let firstAttachment: Attachment = mediaAttachments.first, + var width: CGFloat = firstAttachment.width.map({ CGFloat($0) }), + var height: CGFloat = firstAttachment.height.map({ CGFloat($0) }), + mediaAttachments.count == 1, + width > 0, + height > 0 + else { return defaultSize } + // Honor the content aspect ratio for single media - let albumItem = albumItems.first! - let size = albumItem.mediaSize - guard size.width > 0 && size.height > 0 else { return defaultSize } + let size: CGSize = CGSize(width: width, height: height) var aspectRatio = (size.width / size.height) // Clamp the aspect ratio so that very thin/wide content still looks alright let minAspectRatio: CGFloat = 0.35 let maxAspectRatio = 1 / minAspectRatio - aspectRatio = aspectRatio.clamp(minAspectRatio, maxAspectRatio) let maxSize = CGSize(width: maxMessageWidth, height: maxMessageWidth) - var width: CGFloat - var height: CGFloat + aspectRatio = aspectRatio.clamp(minAspectRatio, maxAspectRatio) + if aspectRatio > 1 { width = maxSize.width height = width / aspectRatio - } else { + } + else { height = maxSize.height width = height * aspectRatio } + // Don't blow up small images unnecessarily let minSize: CGFloat = 150 let shortSourceDimension = min(size.width, size.height) let shortDestinationDimension = min(width, height) + if shortDestinationDimension > minSize && shortDestinationDimension > shortSourceDimension { let factor = minSize / shortDestinationDimension width *= factor; height *= factor } + return CGSize(width: width, height: height) } - static func getMaxWidth(for viewItem: ConversationViewItem) -> CGFloat { - let screen = UIScreen.main.bounds - switch viewItem.interaction.interactionType() { - case .outgoingMessage: return screen.width - contactThreadHSpacing - gutterSize - case .incomingMessage: - let isGroupThread = viewItem.isGroupThread - let leftGutterSize = isGroupThread ? gutterSize : contactThreadHSpacing - return screen.width - leftGutterSize - gutterSize - default: preconditionFailure() + static func getMaxWidth(for item: ConversationViewModel.Item) -> CGFloat { + let screen: CGRect = UIScreen.main.bounds + + switch item.interactionVariant { + case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize) + case .standardIncoming, .standardIncomingDeleted: + let isGroupThread = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup) + let leftGutterSize = (isGroupThread ? gutterSize : contactThreadHSpacing) + + return (screen.width - leftGutterSize - gutterSize) + + default: preconditionFailure() } } - private static func shouldShowProfilePicture(for viewItem: ConversationViewItem) -> Bool { - guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } - let isGroupThread = viewItem.isGroupThread - let senderSessionID = (message as? TSIncomingMessage)?.authorId - return isGroupThread && viewItem.shouldShowSenderProfilePicture && senderSessionID != nil - } - - static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView { + static func getBodyTextView( + for item: ConversationViewModel.Item, + with availableWidth: CGFloat, + textColor: UIColor, + searchText: String?, + delegate: (UITextViewDelegate & BodyTextViewDelegate)? + ) -> UITextView { // Take care of: // • Highlighting mentions // • Linkification // • Highlighting search results - guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() } - let isOutgoing = (message.interactionType() == .outgoingMessage) - let result = BodyTextView(snDelegate: delegate) + // + // Note: We can't just set 'isSelectable' to false otherwise the link detection/selection + // stops working + let isOutgoing: Bool = (item.interactionVariant == .standardOutgoing) + let result: BodyTextView = BodyTextView(snDelegate: delegate) result.isEditable = false - let attributes: [NSAttributedString.Key:Any] = [ - .foregroundColor : textColor, - .font : UIFont.systemFont(ofSize: getFontSize(for: viewItem)) - ] - let attributedText = NSMutableAttributedString(attributedString: MentionUtilities.highlightMentions(in: message.body ?? "", isOutgoingMessage: isOutgoing, threadID: viewItem.interaction.uniqueThreadId, attributes: attributes)) + + let attributedText: NSMutableAttributedString = NSMutableAttributedString( + attributedString: MentionUtilities.highlightMentions( + in: (item.body ?? ""), + threadVariant: item.threadVariant, + isOutgoingMessage: isOutgoing, + attributes: [ + .foregroundColor : textColor, + .font : UIFont.systemFont(ofSize: getFontSize(for: item)) + ] + ) + ) if let searchText = searchText, searchText.count >= ConversationSearchController.kMinimumSearchTextLength { let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText) do { @@ -734,6 +901,7 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { // Do nothing } } + result.attributedText = attributedText result.dataDetectorTypes = .link result.backgroundColor = .clear @@ -744,10 +912,15 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.isScrollEnabled = false result.isUserInteractionEnabled = true result.delegate = delegate - result.linkTextAttributes = [ .foregroundColor : textColor, .underlineStyle : NSUnderlineStyle.single.rawValue ] + result.linkTextAttributes = [ + .foregroundColor: textColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let size = result.sizeThatFits(availableSpace) result.set(.height, to: size.height) + return result } } diff --git a/Session/Conversations/Settings/OWSConversationSettingsViewController.m b/Session/Conversations/Settings/OWSConversationSettingsViewController.m index d4d21a049..079bf4979 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewController.m +++ b/Session/Conversations/Settings/OWSConversationSettingsViewController.m @@ -767,7 +767,7 @@ CGFloat kIconViewLength = 24; - (void)leaveGroup { if (self.isClosedGroup) { - [[SNMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; + [[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; } [self.navigationController popViewControllerAnimated:YES]; @@ -818,7 +818,7 @@ CGFloat kIconViewLength = 24; // If we successfully blocked then force a config sync if (isBlocked) { - [SNMessageSender forceSyncConfigurationNow]; + [SMKMessageSender forceSyncConfigurationNow]; } [weakSelf updateTableContents]; @@ -837,7 +837,7 @@ CGFloat kIconViewLength = 24; // If we successfully unblocked then force a config sync if (!isBlocked) { - [SNMessageSender forceSyncConfigurationNow]; + [SMKMessageSender forceSyncConfigurationNow]; } [weakSelf updateTableContents]; @@ -900,12 +900,8 @@ CGFloat kIconViewLength = 24; { OWSLogDebug(@""); - MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup]; - - self.mediaGallery = mediaGallery; - OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]); - [mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController]; + [SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController]; } - (void)tappedConversationSearch diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 36f514d2a..45d316bc1 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -54,7 +54,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } - collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) + collectionView.register(view: PhotoGridViewCell.self) // ensure images at the end of the list can be scrolled above the bottom buttons let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16 @@ -543,11 +543,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return UICollectionViewCell(forAutoLayout: ()) } - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else { - owsFail("cell was unexpectedly nil") - } - + let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath) cell.loadingColor = UIColor(white: 0.2, alpha: 1) + let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) cell.configure(item: assetItem) diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 0a8ab0dac..bbd855ed3 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -1,3 +1,58 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit + +public class MediaGalleryViewModel { + public let threadId: String + public let threadVariant: SessionThread.Variant + private let item: ConversationViewModel.Item? + + // MARK: - Initialization + + init( + threadId: String, + threadVariant: SessionThread.Variant, + item: ConversationViewModel.Item? = nil + ) { + self.threadId = threadId + self.threadVariant = threadVariant + self.item = item + } + } + + public static func createTileViewController(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool) -> MediaTileViewController { + return MediaTileViewController( + viewModel: MediaGalleryViewModel( + threadId: threadId, + threadVariant: { + if isClosedGroup { return .closedGroup } + if isOpenGroup { return .openGroup } + + return .contact + }() + ) + ) + } +} + +// MARK: - Objective-C Support + +// FIXME: Remove when we can + +@objc(SNMediaGallery) +public class SNMediaGallery: NSObject { + @objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:) + static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) { + fromNavController.pushViewController( + MediaGalleryViewModel.createTileViewController( + threadId: threadId, + isClosedGroup: isClosedGroup, + isOpenGroup: isOpenGroup + ), + animated: true + ) + } +} diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 6958a2c34..6585813e2 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -1,13 +1,13 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation -import SessionUIKit import UIKit +import GRDB +import DifferenceKit +import SessionUIKit +import SignalUtilitiesKit -public protocol MediaTileViewControllerDelegate: class { - func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) +public protocol MediaTileViewControllerDelegate: AnyObject { + func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryViewModel.Item) } public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout { diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index 8d8ca2ad9..aa79acea2 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -16,9 +16,6 @@ public protocol PhotoGridItem: AnyObject { } public class PhotoGridViewCell: UICollectionViewCell { - - static let reuseIdentifier = "PhotoGridViewCell" - public let imageView: UIImageView private let contentTypeBadgeView: UIImageView @@ -128,7 +125,9 @@ public class PhotoGridViewCell: UICollectionViewCell { Logger.debug("image == nil") } - self?.image = image + DispatchQueue.main.async { + self?.image = image + } } switch item.type { diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift new file mode 100644 index 000000000..c4416648e --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -0,0 +1,234 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import PromiseKit + +class MediaDismissAnimationController: NSObject { + private let mediaItem: Media + public let interactionController: MediaInteractiveDismiss? + + var fromView: UIView? + var transitionView: UIView? + var fromTransitionalOverlayView: UIView? + var toTransitionalOverlayView: UIView? + var fromMediaFrame: CGRect? + var pendingCompletion: (() -> ())? + + init(galleryItem: MediaGalleryViewModel.Item, interactionController: MediaInteractiveDismiss? = nil) { + self.mediaItem = .gallery(galleryItem) + self.interactionController = interactionController + } + + init(image: UIImage, interactionController: MediaInteractiveDismiss? = nil) { + self.mediaItem = .image(image) + self.interactionController = interactionController + } +} + +extension MediaDismissAnimationController: 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 + + guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { + transitionContext.completeTransition(false) + return + } + guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { + transitionContext.completeTransition(false) + return + } + + switch fromVC { + case let contextProvider as MediaPresentationContextProvider: + fromContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + switch toVC { + case let contextProvider as MediaPresentationContextProvider: + toContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + toContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { + transitionContext.completeTransition(false) + return + } + + guard let presentationImage: UIImage = mediaItem.image else { + transitionContext.completeTransition(true) + return + } + + // fromView will be nil if doing a presentation, in which case we don't want to add the view - + // it will automatically be added to the view hierarchy, in front of the VC we're presenting from + if let fromView: UIView = transitionContext.view(forKey: .from) { + self.fromView = fromView + containerView.addSubview(fromView) + } + + // toView will be nil if doing a modal dismiss, in which case we don't want to add the view - + // it's already in the view hierarchy, behind the VC we're dismissing. + if let toView: UIView = transitionContext.view(forKey: .to) { + containerView.insertSubview(toView, at: 0) + } + + let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) + let duration: CGFloat = transitionDuration(using: transitionContext) + + fromMediaContext.mediaView.alpha = 0.0 + toMediaContext?.mediaView.alpha = 0.0 + + let transitionView = UIImageView(image: presentationImage) + 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) + + // 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 + + self.pendingCompletion = { + let destinationFromAlpha: CGFloat + let destinationFrame: CGRect + let destinationCornerRadius: CGFloat + + if transitionContext.transitionWasCancelled { + destinationFromAlpha = 1 + destinationFrame = fromMediaContext.presentationFrame + destinationCornerRadius = fromMediaContext.cornerRadius + } + else if let toMediaContext: MediaPresentationContext = toMediaContext { + destinationFromAlpha = 0 + destinationFrame = toMediaContext.presentationFrame + destinationCornerRadius = toMediaContext.cornerRadius + } + 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)) + destinationCornerRadius = fromMediaContext.cornerRadius + } + + UIView.animate( + withDuration: duration, + 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?.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 + // we wouldn't want to remove the toView. + transitionContext.view(forKey: .to)?.removeFromSuperview() + } + else { + transitionContext.view(forKey: .from)?.removeFromSuperview() + } + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + ) + } + + // The interactive transition will call the 'pendingCompletion' when it completes so don't call it here + guard !transitionContext.isInteractive else { return } + + self.pendingCompletion?() + self.pendingCompletion = nil + } +} + +extension MediaDismissAnimationController: InteractiveDismissDelegate { + func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) { + guard let transitionView: UIView = transitionView else { return } // Transition hasn't started yet + guard let fromMediaFrame: CGRect = fromMediaFrame else { return } + + fromView?.alpha = (1.0 - interactiveDismiss.percentComplete) + transitionView.center = fromMediaFrame.offsetBy(dx: offset.x, dy: offset.y).center + } + + func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { + self.pendingCompletion?() + self.pendingCompletion = nil + } +} diff --git a/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift new file mode 100644 index 000000000..3445a3b2f --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift @@ -0,0 +1,108 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +// MARK: - InteractivelyDismissableViewController + +protocol InteractivelyDismissableViewController: UIViewController { + func performInteractiveDismissal(animated: Bool) +} + +// MARK: - InteractiveDismissDelegate + +protocol InteractiveDismissDelegate: AnyObject { + func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) + func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) +} + +// MARK: - MediaInteractiveDismiss + +class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition { + var interactionInProgress = false + + weak var interactiveDismissDelegate: InteractiveDismissDelegate? + private weak var targetViewController: InteractivelyDismissableViewController? + + init(targetViewController: InteractivelyDismissableViewController) { + super.init() + + self.targetViewController = targetViewController + } + + public func addGestureRecognizer(to view: UIView) { + let gesture: DirectionalPanGestureRecognizer = DirectionalPanGestureRecognizer(direction: .vertical, target: self, action: #selector(handleGesture(_:))) + + // Allow panning with trackpad + if #available(iOS 13.4, *) { gesture.allowedScrollTypesMask = .continuous } + + view.addGestureRecognizer(gesture) + } + + // MARK: - Private + + private var fastEnoughToCompleteTransition = false + private var farEnoughToCompleteTransition = false + + private var shouldCompleteTransition: Bool { + if farEnoughToCompleteTransition { return true } + if fastEnoughToCompleteTransition { return true } + + return false + } + + @objc private func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) { + guard let coordinateSpace = gestureRecognizer.view?.superview else { return } + + if case .began = gestureRecognizer.state { + gestureRecognizer.setTranslation(.zero, in: coordinateSpace) + } + + let totalDistance: CGFloat = 100 + let velocityThreshold: CGFloat = 500 + + switch gestureRecognizer.state { + case .began: + interactionInProgress = true + targetViewController?.performInteractiveDismissal(animated: true) + + case .changed: + let velocity = abs(gestureRecognizer.velocity(in: coordinateSpace).y) + if velocity > velocityThreshold { + fastEnoughToCompleteTransition = true + } + + let offset = gestureRecognizer.translation(in: coordinateSpace) + let progress = abs(offset.y) / totalDistance + // `farEnoughToCompleteTransition` is cancelable if the user reverses direction + farEnoughToCompleteTransition = progress >= 0.5 + update(progress) + + interactiveDismissDelegate?.interactiveDismissUpdate(self, didChangeTouchOffset: offset) + + case .cancelled: + interactiveDismissDelegate?.interactiveDismissDidFinish(self) + cancel() + + interactionInProgress = false + farEnoughToCompleteTransition = false + fastEnoughToCompleteTransition = false + + case .ended: + if shouldCompleteTransition { + finish() + } + else { + cancel() + } + + interactiveDismissDelegate?.interactiveDismissDidFinish(self) + + interactionInProgress = false + farEnoughToCompleteTransition = false + fastEnoughToCompleteTransition = false + + default: + break + } + } +} diff --git a/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift new file mode 100644 index 000000000..e37ea56c2 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift @@ -0,0 +1,50 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum Media { + case gallery(MediaGalleryViewModel.Item) + case image(UIImage) + + var image: UIImage? { + switch self { + case let .gallery(item): + guard let originalFilePath: String = item.attachment.originalFilePath else { return nil } + + return UIImage(contentsOfFile: originalFilePath) + + case let .image(image): return image + } + } +} + +struct MediaPresentationContext { + let mediaView: UIView + let presentationFrame: CGRect + let cornerRadius: CGFloat + let cornerMask: CACornerMask +} + +// There are two kinds of AnimationControllers that interact with the media detail view. Both +// appear to transition the media view from one VC to it's corresponding location in the +// destination VC. +// +// MediaPresentationContextProvider is either a target or destination VC which can provide the +// details necessary to facilite this animation. +// +// First, the MediaZoomAnimationController is non-interactive. We use it whenever we're going to +// show the Media detail pager. +// +// We can get there several ways: +// From conversation settings, this can be a push or a pop from the tileView. +// From conversationView/MessageDetails this can be a modal present or a pop from the tile view. +// +// The other animation controller, the MediaDismissAnimationController is used when we're going to +// stop showing the media pager. This can be a pop to the tile view, or a modal dismiss. +protocol MediaPresentationContextProvider { + func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? + + // The transitionView will be presented below this view. + // If nil, the transitionView will be presented above all + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? +} diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift new file mode 100644 index 000000000..4f99d1842 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -0,0 +1,189 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +class MediaZoomAnimationController: NSObject { + private let mediaItem: Media + + init(image: UIImage) { + mediaItem = .image(image) + } + + init(galleryItem: MediaGalleryViewModel.Item) { + mediaItem = .gallery(galleryItem) + } +} + +extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.4 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let containerView = transitionContext.containerView + let fromContextProvider: MediaPresentationContextProvider + let toContextProvider: MediaPresentationContextProvider + + guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else { + transitionContext.completeTransition(false) + return + } + guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else { + transitionContext.completeTransition(false) + return + } + + switch fromVC { + case let contextProvider as MediaPresentationContextProvider: + fromContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + switch toVC { + case let contextProvider as MediaPresentationContextProvider: + toContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + toContextProvider = contextProvider + + default: + transitionContext.completeTransition(false) + return + } + + // '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 + // + // Note: We *MUST* call 'layoutIfNeeded' prior to 'toContextProvider.mediaPresentationContext' + // as 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 oldToViewSuperview: UIView? = toView.superview + toView.layoutIfNeeded() + + guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { + transitionContext.completeTransition(false) + return + } + + guard let toMediaContext: MediaPresentationContext = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else { + transitionContext.completeTransition(false) + return + } + + guard let presentationImage: UIImage = mediaItem.image else { + transitionContext.completeTransition(true) + return + } + + let duration: CGFloat = transitionDuration(using: transitionContext) + + fromMediaContext.mediaView.alpha = 0 + toMediaContext.mediaView.alpha = 0 + + let fromSnapshotView: UIView = (fromVC.view.snapshotView(afterScreenUpdates: false) ?? UIView()) + containerView.addSubview(fromSnapshotView) + + toView.frame = containerView.bounds + toView.alpha = 0 + containerView.addSubview(toView) + + let transitionView = UIImageView(image: presentationImage) + 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) + + let overshootPercentage: CGFloat = 0.15 + let overshootFrame: CGRect = CGRect( + x: (toMediaContext.presentationFrame.minX + ((toMediaContext.presentationFrame.minX - fromMediaContext.presentationFrame.minX) * overshootPercentage)), + y: (toMediaContext.presentationFrame.minY + ((toMediaContext.presentationFrame.minY - fromMediaContext.presentationFrame.minY) * overshootPercentage)), + width: (toMediaContext.presentationFrame.width + ((toMediaContext.presentationFrame.width - fromMediaContext.presentationFrame.width) * overshootPercentage)), + height: (toMediaContext.presentationFrame.height + ((toMediaContext.presentationFrame.height - fromMediaContext.presentationFrame.height) * overshootPercentage)) + ) + + // 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 + } + + overlayView.frame = overlayViewFrame + containerView.addSubview(overlayView) + + return overlayView + }() + let toTransitionalOverlayView: UIView? = { + guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else { + return nil + } + + overlayView.alpha = 0 + overlayView.frame = overlayViewFrame + containerView.addSubview(overlayView) + + return overlayView + }() + + UIView.animate( + withDuration: (duration / 2), + delay: 0, + options: .curveEaseOut, + 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 = overshootFrame + transitionView.layer.cornerRadius = toMediaContext.cornerRadius + }, + completion: { _ in + UIView.animate( + withDuration: (duration / 2), + delay: 0, + options: .curveEaseInOut, + animations: { + transitionView.frame = toMediaContext.presentationFrame + }, + completion: { _ in + 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) + } + ) + } + ) + } +} diff --git a/Session/Utilities/UINavigationBar+Utilities.swift b/Session/Utilities/UINavigationBar+Utilities.swift new file mode 100644 index 000000000..09ba0a8bf --- /dev/null +++ b/Session/Utilities/UINavigationBar+Utilities.swift @@ -0,0 +1,44 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +extension UINavigationBar { + func generateSnapshot(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + let scale = UIScreen.main.scale + + guard let navBarSuperview: UIView = superview else { return nil } + + UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, scale) + + guard let context: CGContext = UIGraphicsGetCurrentContext() else { + UIGraphicsEndImageContext() + return nil + } + + layer.render(in: context) + + guard let image: UIImage = UIGraphicsGetImageFromCurrentImageContext() else { + UIGraphicsEndImageContext() + return nil + } + UIGraphicsEndImageContext() + + let snapshotView: UIView = UIView( + frame: CGRect( + x: 0, + y: 0, + width: bounds.width, + height: frame.maxY + ) + ) + snapshotView.backgroundColor = backgroundColor + + let imageView: UIImageView = UIImageView(image: image) + imageView.frame = frame + snapshotView.addSubview(imageView) + + let presentationFrame = coordinateSpace.convert(snapshotView.frame, from: navBarSuperview) + + return (snapshotView, presentationFrame) + } +} diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 5b8ea6b1c..0e03a9035 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -1110,7 +1110,7 @@ public enum Legacy { internal final class _AttachmentUploadJob: NSObject, NSCoding { internal let attachmentID: String internal let threadID: String - internal let message: Message + internal let message: _Message internal let messageSendJobID: String internal var id: String? internal var failureCount: UInt = 0 @@ -1121,7 +1121,7 @@ public enum Legacy { guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, let threadID = coder.decodeObject(forKey: "threadID") as! String?, - let message = coder.decodeObject(forKey: "message") as! Message?, + let message = coder.decodeObject(forKey: "message") as! _Message?, let messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?, let id = coder.decodeObject(forKey: "id") as! String? else { return nil } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 3d3a7f3cb..b2d4a0f98 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -229,6 +229,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.width, .integer) t.column(.height, .integer) t.column(.duration, .double) + t.column(.isVisualMedia, .boolean) + .notNull() + .defaults(to: false) t.column(.isValid, .boolean) .notNull() .defaults(to: false) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 04f345630..ea638d918 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -969,7 +969,7 @@ enum _003_YDBToGRDBMigration: Migration { } transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in - guard let job = object as? Legacy.AttachmentUploadJob else { return } + guard let job = object as? Legacy._AttachmentUploadJob else { return } attachmentUploadJobs.insert(job) } @@ -1059,7 +1059,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - --messageSend - var messageSendJobIdMap: [String: Int64] = [:] + var messageSendJobLegacyMap: [String: Job] = [:] try autoreleasepool { try messageSendJobs.forEach { legacyJob in @@ -1132,31 +1132,42 @@ enum _003_YDBToGRDBMigration: Migration { )?.inserted(db) if let oldId: String = legacyJob.id, let newId: Int64 = job?.id { - messageSendJobIdMap[oldId] = newId + messageSendJobLegacyMap[oldId] = job } } } // MARK: - --attachmentUpload - + try autoreleasepool { try attachmentUploadJobs.forEach { legacyJob in - guard let sendJobId: Int64 = messageSendJobIdMap[legacyJob.messageSendJobID] else { + guard let sendJob: Job = messageSendJobLegacyMap[legacyJob.messageSendJobID], let sendJobId: Int64 = sendJob.id else { SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob") throw GRDBStorageError.migrationFailed } - - _ = try Job( + + let uploadJob: Job? = try Job( failureCount: legacyJob.failureCount, variant: .attachmentUpload, behaviour: .runOnce, - nextRunTimestamp: 0, + threadId: legacyJob.threadID, + interactionId: sendJob.interactionId, details: AttachmentUploadJob.Details( - threadId: legacyJob.threadID, - attachmentId: legacyJob.attachmentID, - messageSendJobId: sendJobId + messageSendJobId: sendJobId, + attachmentId: legacyJob.attachmentID ) )?.inserted(db) + + // Add the dependency to the relevant MessageSendJob + guard let uploadJobId: Int64 = uploadJob?.id else { + SNLog("[Migration Error] attachmentUpload job was not created") + throw GRDBStorageError.migrationFailed + } + + try JobDependencies( + jobId: sendJobId, + dependantId: uploadJobId + ).insert(db) } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 444008f2e..6fa2c67d3 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -10,7 +10,7 @@ import AVFoundation public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - public static let interactionAttachments = hasOne(InteractionAttachment.self) + internal static let interactionAttachments = hasOne(InteractionAttachment.self) internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId]) internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) public static let interaction = hasOne( @@ -36,6 +36,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case width case height case duration + case isVisualMedia case isValid case encryptionKey case digest @@ -109,6 +110,9 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR /// The number of seconds the attachment plays for (this will only be set for video and audio attachment types) public let duration: TimeInterval? + /// A flag indicating whether the attachment data is visual media + public let isVisualMedia: Bool + /// A flag indicating whether the attachment data downloaded is valid for it's content type public let isValid: Bool @@ -137,6 +141,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR width: UInt? = nil, height: UInt? = nil, duration: TimeInterval? = nil, + isVisualMedia: Bool? = nil, isValid: Bool = false, encryptionKey: Data? = nil, digest: Data? = nil, @@ -155,6 +160,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.width = width self.height = height self.duration = duration + self.isVisualMedia = (isVisualMedia ?? ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + )) self.isValid = isValid self.encryptionKey = encryptionKey self.digest = digest @@ -166,9 +176,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR id: String = UUID().uuidString, variant: Variant = .standard, contentType: String, - dataSource: DataSource + dataSource: DataSource, + sourceFilename: String? = nil, + caption: String? = nil ) { - guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: nil) else { + guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename) else { return nil } guard dataSource.write(toPath: originalFilePath) else { return nil } @@ -190,16 +202,21 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.contentType = contentType self.byteCount = dataSource.dataLength() self.creationTimestamp = nil - self.sourceFilename = nil + self.sourceFilename = sourceFilename self.downloadUrl = nil self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.duration = duration + self.isVisualMedia = ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ) self.isValid = isValid self.encryptionKey = nil self.digest = nil - self.caption = nil + self.caption = caption } // MARK: - Custom Database Interaction @@ -309,6 +326,7 @@ public extension Attachment { width: width, height: height, duration: duration, + isVisualMedia: isVisualMedia, isValid: isValid, encryptionKey: (encryptionKey ?? self.encryptionKey), digest: (digest ?? self.digest), @@ -353,6 +371,11 @@ public extension Attachment { self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) self.duration = nil // Needs to be downloaded to be set + self.isVisualMedia = ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ) self.isValid = false // Needs to be downloaded to be set self.encryptionKey = proto.key self.digest = proto.digest @@ -702,8 +725,6 @@ extension Attachment { public var isText: Bool { MIMETypeUtil.isText(contentType) } public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) } - public var isVisualMedia: Bool { isImage || isVideo || isAnimated } - public func readDataFromFile() throws -> Data? { guard let filePath: String = self.originalFilePath else { return nil @@ -716,7 +737,7 @@ extension Attachment { return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg" } - private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { + private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) { guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else { failure() return @@ -730,43 +751,113 @@ extension Attachment { return } - success(image) + success( + image, + { + guard let originalFilePath: String = originalFilePath else { throw AttachmentError.invalidData } + + return try Data(contentsOf: URL(fileURLWithPath: originalFilePath)) + } + ) return } let thumbnailPath = thumbnailPath(for: dimensions) if FileManager.default.fileExists(atPath: thumbnailPath) { - guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else { + guard + let data: Data = try? Data(contentsOf: URL(fileURLWithPath: thumbnailPath)), + let image: UIImage = UIImage(data: data) + else { failure() return } - success(image) + success(image, { data }) return } OWSThumbnailService.shared.ensureThumbnail( for: self, dimensions: dimensions, - success: { loadedThumbnail in success(loadedThumbnail.image) }, + success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) }, failure: { _ in failure() } ) } - public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) { + public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) { loadThumbnail(with: size.dimension, success: success, failure: failure) } - public func cloneAsThumbnail() -> Attachment { - fatalError("TODO: Add this back") + public func cloneAsThumbnail() -> Attachment? { + let cloneId: String = UUID().uuidString + let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")" + + guard + self.isValid, + self.isVisualMedia, + let thumbnailPath: String = Attachment.originalFilePath( + id: cloneId, + mimeType: OWSMimeTypeImageJpeg, + sourceFilename: thumbnailName + ) + else { return nil } + + // Try generate the thumbnail + var thumbnailData: Data? + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + + self.thumbnail( + size: .small, + success: { _, dataSourceBlock in + thumbnailData = try? dataSourceBlock() + semaphore.signal() + }, + failure: { semaphore.signal() } + ) + + // Wait up to 0.5 seconds + _ = semaphore.wait(timeout: .now() + .milliseconds(500)) + + guard let thumbnailData: Data = thumbnailData else { return nil } + + // Write the quoted thumbnail to disk + do { try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath)) } + catch { return nil } + + // Need to retrieve the size of the thumbnail as it maintains it's aspect ratio + let thumbnailSize: CGSize = Attachment + .imageSize( + contentType: OWSMimeTypeImageJpeg, + originalFilePath: thumbnailPath + ) + .defaulting( + to: CGSize( + width: Int(ThumbnailSize.small.dimension), + height: Int(ThumbnailSize.small.dimension) + ) + ) + + // Copy the thumbnail to a new attachment + return Attachment( + id: cloneId, + variant: .standard, + contentType: OWSMimeTypeImageJpeg, + byteCount: UInt(thumbnailData.count), + sourceFilename: thumbnailName, + localRelativeFilePath: thumbnailPath + .substring(from: (Attachment.attachmentsFolder.count + 1)), // Leading forward slash + width: UInt(thumbnailSize.width), + height: UInt(thumbnailSize.height), + isValid: true + ) } public func write(data: Data) throws -> Bool { guard let originalFilePath: String = originalFilePath else { return false } try data.write(to: URL(fileURLWithPath: originalFilePath)) - + return true } } @@ -873,6 +964,10 @@ extension Attachment { .with( serverId: "\(fileId)", state: .uploaded, + creationTimestamp: ( + updatedAttachment?.creationTimestamp ?? + Date().timeIntervalSince1970 + ), downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)" ) .saved(db) diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index ae75a0631..50f0dab0c 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -8,7 +8,7 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord public static var databaseTableName: String { "interactionAttachment" } internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) - internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + public static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey) public typealias Columns = CodingKeys diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 953b9b9bc..76782c689 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -154,7 +154,7 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - @discardableResult static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { + static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil } diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 3f1c27f86..0fd8b4b71 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -106,6 +106,7 @@ public extension Quote { quote.id != 0, !quote.author.isEmpty else { return nil } + self.interactionId = interactionId self.timestampMs = Int64(quote.id) self.authorId = quote.author @@ -128,27 +129,24 @@ public extension Quote { } // We only use the first attachment - if let attachment = proto.attachments.first { - let thumbnailAttachment: Attachment - - // We prefer deriving any thumbnail locally rather than fetching one from the network - if let quotedInteraction: Interaction = quotedInteraction { - if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { - thumbnailAttachment = attachment.cloneAsThumbnail() + if let attachment = quote.attachments.first(where: { $0.thumbnail != nil })?.thumbnail { + self.attachmentId = try quotedInteraction + .map { quotedInteraction -> Attachment? in + // If the quotedInteraction has an attachment then try clone it + if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { + return attachment.cloneAsThumbnail() + } + + // Otherwise if the quotedInteraction has a link preview, try clone that + return try? quotedInteraction.linkPreview + .fetchOne(db)? + .attachment + .fetchOne(db)? + .cloneAsThumbnail() } - else if let linkPreviewAttachment: Attachment = try? quotedInteraction.linkPreview.fetchOne(db)?.attachment.fetchOne(db) { - thumbnailAttachment = linkPreviewAttachment.cloneAsThumbnail() - } - else { - thumbnailAttachment = Attachment(proto: attachment) - } - } - else { - thumbnailAttachment = Attachment(proto: attachment) - } - - try thumbnailAttachment.save(db) - self.attachmentId = thumbnailAttachment.id + .defaulting(to: Attachment(proto: attachment)) + .inserted(db) + .id } else { self.attachmentId = nil @@ -158,6 +156,5 @@ public extension Quote { if self.body == nil && self.attachmentId == nil { return nil } - } } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 0b8cd22fd..72efd9ce4 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -101,7 +101,7 @@ public enum AttachmentDownloadJob: JobExecutor { state: .downloaded, creationTimestamp: Date().timeIntervalSince1970, localRelativeFilePath: attachment.originalFilePath? - .substring(from: Attachment.attachmentsFolder.count) + .substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash ) .save(db) } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 57fc04a9e..827512b06 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -10,6 +10,7 @@ public enum AttachmentUploadJob: JobExecutor { public static var maxFailureCount: Int = 10 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = true + public static func run( _ job: Job, success: @escaping (Job, Bool) -> (), @@ -51,9 +52,18 @@ public enum AttachmentUploadJob: JobExecutor { extension AttachmentUploadJob { public struct Details: Codable { + /// This is the id for the messageSend job this attachmentUpload job is associated to, the value isn't used for any of + /// the logic but we want to mandate that the attachmentUpload job can only be used alongside a messageSend job + /// + /// **Note:** If we do decide to remove this the `_003_YDBToGRDBMigration` will need to be updated as it + /// fails if this connection can't be made + public let messageSendJobId: Int64 + + /// The id of the `Attachment` to upload public let attachmentId: String - public init(attachmentId: String) { + public init(messageSendJobId: Int64, attachmentId: String) { + self.messageSendJobId = messageSendJobId self.attachmentId = attachmentId } } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index c2066c47f..502ce51b1 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -71,6 +71,7 @@ public enum MessageSendJob: JobExecutor { threadId: job.threadId, interactionId: interactionId, details: AttachmentUploadJob.Details( + messageSendJobId: jobId, attachmentId: stateInfo.attachmentId ) ), diff --git a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift b/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift deleted file mode 100644 index 6bb271d05..000000000 --- a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SignalCoreKit - -@objc(OWSTypingIndicatorInteraction) -public class TypingIndicatorInteraction: TSInteraction { - @objc - public static let TypingIndicatorId = "TypingIndicator" - - @objc - public override func isDynamicInteraction() -> Bool { - return true - } - - @objc - public override func interactionType() -> OWSInteractionType { - return .typingIndicator - } - - @available(*, unavailable, message:"use other constructor instead.") - @objc - public required init(coder aDecoder: NSCoder) { - notImplemented() - } - - @available(*, unavailable, message:"use other constructor instead.") - @objc - public required init(dictionary dictionaryValue: [String: Any]!) throws { - notImplemented() - } - - @objc - public let recipientId: String - - @objc - public init(thread: TSThread, timestamp: UInt64, recipientId: String) { - self.recipientId = recipientId - - super.init(interactionWithUniqueId: TypingIndicatorInteraction.TypingIndicatorId, - timestamp: timestamp, in: thread) - } - - @objc - public override func save(with transaction: YapDatabaseReadWriteTransaction) { - owsFailDebug("The transient interaction should not be saved in the database.") - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index 87076013a..fa42ccc75 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -476,15 +476,6 @@ extension MessageSender { return promise } - @objc(leaveClosedGroupWithPublicKey:) - public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { - let promise = GRDBStorage.shared.write { db in - try leave(db, groupPublicKey: groupPublicKey) - } - - return AnyPromise.from(promise) - } - /// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the /// user is a regular member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave /// message). The admin can then truly remove them later. diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 8bbb9a081..c58632fed 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -11,8 +11,8 @@ extension MessageSender { public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws { guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } - // TODO: Is the 'prep' method needed anymore? -// prep(db, attachments, for: message) + + try prep(db, signalAttachments: attachments, for: interactionId) send( db, message: VisibleMessage.from(db, interaction: interaction), @@ -175,12 +175,3 @@ extension MessageSender { return promise } } - -extension MessageSender { - @objc(forceSyncConfigurationNow) - public static func objc_forceSyncConfigurationNow() { - GRDBStorage.shared.write { db in - try syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 51a059a4d..271ef3bfb 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -6,63 +6,36 @@ import PromiseKit import SessionSnodeKit import SessionUtilitiesKit -@objc(SNMessageSender) -public final class MessageSender : NSObject { - // MARK: Initialization - private override init() { } - +public final class MessageSender { // MARK: - Preparation public static func prep( _ db: Database, signalAttachments: [SignalAttachment], - for message: VisibleMessage - ) { - guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { - #if DEBUG - preconditionFailure() - #else - return - #endif + for interactionId: Int64 + ) throws { + try signalAttachments.forEach { signalAttachment in + let maybeAttachment: Attachment? = Attachment( + variant: (signalAttachment.isVoiceMessage ? + .voiceMessage : + .standard + ), + contentType: signalAttachment.mimeType, + dataSource: signalAttachment.dataSource, + sourceFilename: signalAttachment.sourceFilename, + caption: signalAttachment.captionText + ) + + guard let attachment: Attachment = maybeAttachment else { return } + + let interactionAttachment: InteractionAttachment = InteractionAttachment( + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) } - var attachments: [TSAttachmentStream] = [] - signalAttachments.forEach { - let attachment = TSAttachmentStream(contentType: $0.mimeType, byteCount: UInt32($0.dataLength), sourceFilename: $0.sourceFilename, - caption: $0.captionText, albumMessageId: tsMessage.uniqueId!) - attachment.attachmentType = $0.isVoiceMessage ? .voiceMessage : .default - attachments.append(attachment) - attachment.write($0.dataSource) - attachment.save(with: transaction) - } - prep(attachments, for: message, using: transaction) - } - - @objc(prep:forMessage:usingTransaction:) - public static func prep(_ attachmentStreams: [TSAttachmentStream], for message: VisibleMessage, using transaction: YapDatabaseReadWriteTransaction) { - guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { - #if DEBUG - preconditionFailure() - #else - return - #endif - } - var attachments = attachmentStreams - // The line below locally generates a thumbnail for the quoted attachment. It just needs to happen at some point during the - // message sending process. - tsMessage.quotedMessage?.createThumbnailAttachmentsIfNecessary(with: transaction) - var linkPreviewAttachmentID: String? - if let id = tsMessage.linkPreview?.imageAttachmentId, - let attachment = TSAttachment.fetch(uniqueId: id, transaction: transaction) as? TSAttachmentStream { - linkPreviewAttachmentID = id - attachments.append(attachment) - } - // Anything added to message.attachmentIDs will be uploaded by an UploadAttachmentJob. Any attachment IDs added to tsMessage will - // make it render as an attachment (not what we want in the case of a link preview or quoted attachment). - message.attachmentIDs = attachments.map { $0.uniqueId! } - tsMessage.attachmentIds.removeAllObjects() - tsMessage.attachmentIds.addObjects(from: message.attachmentIDs) - if let id = linkPreviewAttachmentID { tsMessage.attachmentIds.remove(id) } - tsMessage.save(with: transaction) } // MARK: - Convenience @@ -559,3 +532,26 @@ public final class MessageSender : NSObject { return nil } } + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKMessageSender) +public class SMKMessageSender: NSObject { + @objc(leaveClosedGroupWithPublicKey:) + public static func objc_leave(_ groupPublicKey: String) -> AnyPromise { + let promise = GRDBStorage.shared.write { db in + try MessageSender.leave(db, groupPublicKey: groupPublicKey) + } + + return AnyPromise.from(promise) + } + + @objc(forceSyncConfigurationNow) + public static func objc_forceSyncConfigurationNow() { + GRDBStorage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index fc73325de..a1c8cbec4 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -112,3 +112,16 @@ public struct QuotedReplyModel { ) } } + +// MARK: - Convenience + +public extension QuotedReplyModel { + func generateAttachmentThumbnailIfNeeded(_ db: Database) throws -> String? { + guard let sourceAttachment: Attachment = self.attachment else { return nil } + + return try sourceAttachment + .cloneAsThumbnail()? + .inserted(db) + .id + } +} diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 875f56307..c652105a9 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -6,7 +6,9 @@ import GRDB public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) + internal static let dependantJobForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.jobId]) internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey) + internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -153,6 +155,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer request(for: Job.dependencies) } + /// The other jobs which depend on this job + /// + /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is + /// deleted or it will automatically delete any dependant jobs + public var dependantJobs: QueryInterfaceRequest { + request(for: Job.dependantJobs) + } + // MARK: - Initialization fileprivate init( @@ -225,7 +235,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer public func delete(_ db: Database) throws -> Bool { // Delete any dependencies - try dependencies + try dependantJobs .deleteAll(db) return try performDelete(db) diff --git a/SessionUtilitiesKit/General/ReusableView.swift b/SessionUtilitiesKit/General/ReusableView.swift index 4a33f2e65..032b624c6 100644 --- a/SessionUtilitiesKit/General/ReusableView.swift +++ b/SessionUtilitiesKit/General/ReusableView.swift @@ -12,5 +12,6 @@ public extension ReusableView where Self: UIView { } } +extension UICollectionReusableView: ReusableView {} extension UITableViewCell: ReusableView {} extension UITableViewHeaderFooterView: ReusableView {} diff --git a/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift b/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift new file mode 100644 index 000000000..fbbc7cd33 --- /dev/null +++ b/SessionUtilitiesKit/General/UICollectionView+ReusableView.swift @@ -0,0 +1,27 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UICollectionView { + func register(view: View.Type) where View: UICollectionViewCell { + register(view.self, forCellWithReuseIdentifier: view.defaultReuseIdentifier) + } + + func register(view: View.Type, ofKind kind: String) where View: UICollectionReusableView { + register(view.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: view.defaultReuseIdentifier) + } + + func dequeue(type: T.Type, for indexPath: IndexPath) -> T where T: UICollectionViewCell { + // Note: We need to use `type.defaultReuseIdentifier` rather than `T.defaultReuseIdentifier` + // otherwise we may get a subclass rather than the actual type we specified + let reuseIdentifier = type.defaultReuseIdentifier + return dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! T + } + + func dequeue(type: T.Type, ofKind kind: String, for indexPath: IndexPath) -> T where T: UICollectionReusableView { + // Note: We need to use `type.defaultReuseIdentifier` rather than `T.defaultReuseIdentifier` + // otherwise we may get a subclass rather than the actual type we specified + let reuseIdentifier = type.defaultReuseIdentifier + return dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! T + } +} diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 84ab69c7b..c3298d133 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -524,6 +524,7 @@ public final class JobRunner { GRDBStorage.shared.write { db in // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + getRetryInterval(for: job)) guard !permanentFailure && @@ -537,12 +538,36 @@ public final class JobRunner { } SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") + _ = try job .with( failureCount: (job.failureCount + 1), - nextRunTimestamp: (Date().timeIntervalSince1970 + getRetryInterval(for: job)) + nextRunTimestamp: nextRunTimestamp ) .saved(db) + + // Update the failureCount and nextRunTimestamp on dependant jobs as well (update the + // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated it'll + // come after the dependency) + try job.dependantJobs + .updateAll( + db, + Job.Columns.failureCount.set(to: job.failureCount), + Job.Columns.nextRunTimestamp.set(to: (nextRunTimestamp + (1 / 1000))) + ) + + let dependantJobIds: [Int64] = try job.dependantJobs + .select(.id) + .asRequest(of: Int64.self) + .fetchAll(db) + + // Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying + // to run dependecies indefinitely + if !dependantJobIds.isEmpty { + jobQueue.mutate { queue in + queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } + } + } } jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index b6f43c54b..94dabe8ee 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -5,11 +5,11 @@ import PromiseKit import SessionUIKit -public protocol GalleryRailItemProvider: AnyObject { +public protocol GalleryRailItemProvider { var railItems: [GalleryRailItem] { get } } -public protocol GalleryRailItem: AnyObject { +public protocol GalleryRailItem { func buildRailItemView() -> UIView }