mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
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
This commit is contained in:
parent
3f062c044c
commit
8f120c4380
|
@ -214,7 +214,6 @@
|
||||||
B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
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 */; };
|
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; };
|
||||||
B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.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 */; };
|
B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; };
|
||||||
B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; };
|
B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; };
|
||||||
B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.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 */; };
|
FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; };
|
||||||
FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.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 */; };
|
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 */; };
|
FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; };
|
||||||
FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; };
|
FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; };
|
||||||
FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.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 */; };
|
FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; };
|
||||||
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; };
|
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; };
|
||||||
FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.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 */; };
|
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; };
|
||||||
FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.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 */; };
|
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 */; };
|
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 */; };
|
FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */; };
|
||||||
FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -1006,7 +1011,6 @@
|
||||||
34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = "<group>"; };
|
34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = "<group>"; };
|
||||||
34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = "<group>"; };
|
34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = "<group>"; };
|
||||||
34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = "<group>"; };
|
34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = "<group>"; };
|
||||||
34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorInteraction.swift; sourceTree = "<group>"; };
|
|
||||||
34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = "<group>"; };
|
34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = "<group>"; };
|
||||||
34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerLayout.swift; sourceTree = "<group>"; };
|
34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerLayout.swift; sourceTree = "<group>"; };
|
||||||
34C3C78C20409F320000134C /* Opening.m4r */ = {isa = PBXFileReference; lastKnownFileType = file; path = Opening.m4r; sourceTree = "<group>"; };
|
34C3C78C20409F320000134C /* Opening.m4r */ = {isa = PBXFileReference; lastKnownFileType = file; path = Opening.m4r; sourceTree = "<group>"; };
|
||||||
|
@ -1798,6 +1802,7 @@
|
||||||
FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = "<group>"; };
|
FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = "<group>"; };
|
||||||
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
|
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
|
||||||
FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = "<group>"; };
|
FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacyModels.swift; sourceTree = "<group>"; };
|
||||||
|
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = "<group>"; };
|
||||||
FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = "<group>"; };
|
FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = "<group>"; };
|
||||||
FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = "<group>"; };
|
FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = "<group>"; };
|
||||||
FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = "<group>"; };
|
FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBStorage.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1829,6 +1834,7 @@
|
||||||
FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = "<group>"; };
|
FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = "<group>"; };
|
||||||
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = "<group>"; };
|
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = "<group>"; };
|
||||||
|
FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = "<group>"; };
|
||||||
FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
|
FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
|
||||||
FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = "<group>"; };
|
FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = "<group>"; };
|
||||||
FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
|
FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1849,6 +1855,10 @@
|
||||||
FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
|
FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
|
||||||
FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TableRecord+Utilities.swift"; sourceTree = "<group>"; };
|
FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TableRecord+Utilities.swift"; sourceTree = "<group>"; };
|
||||||
FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
|
FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
|
||||||
|
FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDismissAnimationController.swift; sourceTree = "<group>"; };
|
||||||
|
FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaInteractiveDismiss.swift; sourceTree = "<group>"; };
|
||||||
|
FDFDE127282D05530098B17F /* MediaPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPresentationContext.swift; sourceTree = "<group>"; };
|
||||||
|
FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
@ -2080,6 +2090,7 @@
|
||||||
B886B4A82398BA1500211ABE /* QRCode.swift */,
|
B886B4A82398BA1500211ABE /* QRCode.swift */,
|
||||||
B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */,
|
B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */,
|
||||||
B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */,
|
B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */,
|
||||||
|
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */,
|
||||||
C31A6C59247F214E001123EF /* UIView+Glow.swift */,
|
C31A6C59247F214E001123EF /* UIView+Glow.swift */,
|
||||||
C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */,
|
C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */,
|
||||||
FD859EFF27C4691300510D0C /* MockDataGenerator.swift */,
|
FD859EFF27C4691300510D0C /* MockDataGenerator.swift */,
|
||||||
|
@ -2396,6 +2407,7 @@
|
||||||
C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */,
|
C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */,
|
||||||
C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */,
|
C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */,
|
||||||
FD705A91278D051200F16121 /* ReusableView.swift */,
|
FD705A91278D051200F16121 /* ReusableView.swift */,
|
||||||
|
FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */,
|
||||||
FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */,
|
FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */,
|
||||||
C38EF23D255B6D66007E1867 /* UIView+OWS.h */,
|
C38EF23D255B6D66007E1867 /* UIView+OWS.h */,
|
||||||
C38EF23E255B6D66007E1867 /* UIView+OWS.m */,
|
C38EF23E255B6D66007E1867 /* UIView+OWS.m */,
|
||||||
|
@ -2613,7 +2625,6 @@
|
||||||
C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */,
|
C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */,
|
||||||
C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */,
|
C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */,
|
||||||
B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */,
|
B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */,
|
||||||
34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */,
|
|
||||||
);
|
);
|
||||||
path = Signal;
|
path = Signal;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2890,6 +2901,7 @@
|
||||||
C36096BA25AD1B14008B62B2 /* Media Viewing & Editing */ = {
|
C36096BA25AD1B14008B62B2 /* Media Viewing & Editing */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FDFDE122282D04E30098B17F /* Transitions */,
|
||||||
C36096B925AD1ACF008B62B2 /* GIFs */,
|
C36096B925AD1ACF008B62B2 /* GIFs */,
|
||||||
34E3E5671EC4B19400495BAC /* AudioProgressView.swift */,
|
34E3E5671EC4B19400495BAC /* AudioProgressView.swift */,
|
||||||
346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */,
|
346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */,
|
||||||
|
@ -3755,6 +3767,17 @@
|
||||||
path = Errors;
|
path = Errors;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
FDFDE122282D04E30098B17F /* Transitions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */,
|
||||||
|
FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */,
|
||||||
|
FDFDE127282D05530098B17F /* MediaPresentationContext.swift */,
|
||||||
|
FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */,
|
||||||
|
);
|
||||||
|
path = Transitions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXHeadersBuildPhase section */
|
/* Begin PBXHeadersBuildPhase section */
|
||||||
|
@ -4749,6 +4772,7 @@
|
||||||
B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */,
|
B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */,
|
||||||
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */,
|
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */,
|
||||||
FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */,
|
FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */,
|
||||||
|
FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */,
|
||||||
FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */,
|
FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */,
|
||||||
FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */,
|
FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */,
|
||||||
C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */,
|
C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */,
|
||||||
|
@ -4945,7 +4969,6 @@
|
||||||
C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */,
|
C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */,
|
||||||
C352A30925574D8500338F3E /* Message+Destination.swift in Sources */,
|
C352A30925574D8500338F3E /* Message+Destination.swift in Sources */,
|
||||||
C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */,
|
C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */,
|
||||||
B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */,
|
|
||||||
C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */,
|
C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */,
|
||||||
C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */,
|
C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */,
|
||||||
C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */,
|
C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */,
|
||||||
|
@ -4991,6 +5014,7 @@
|
||||||
FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */,
|
FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */,
|
||||||
B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */,
|
B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */,
|
||||||
B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */,
|
B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */,
|
||||||
|
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */,
|
||||||
452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */,
|
452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */,
|
||||||
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
||||||
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
||||||
|
@ -5043,9 +5067,12 @@
|
||||||
4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */,
|
4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */,
|
||||||
B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */,
|
B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */,
|
||||||
B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */,
|
B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */,
|
||||||
|
FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */,
|
||||||
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
|
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
|
||||||
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
|
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
|
||||||
|
FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */,
|
||||||
FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */,
|
FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */,
|
||||||
|
FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */,
|
||||||
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
|
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
|
||||||
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
|
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
|
||||||
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
|
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
|
||||||
|
@ -5134,6 +5161,7 @@
|
||||||
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */,
|
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */,
|
||||||
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
|
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
|
||||||
45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */,
|
45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */,
|
||||||
|
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */,
|
||||||
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
|
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
|
||||||
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
|
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
|
||||||
B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */,
|
B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */,
|
||||||
|
|
|
@ -301,7 +301,6 @@ extension ConversationVC:
|
||||||
let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
|
let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
|
||||||
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
|
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
|
||||||
|
|
||||||
for: self.thread,
|
|
||||||
approveMessageRequestIfNeeded(
|
approveMessageRequestIfNeeded(
|
||||||
for: thread,
|
for: thread,
|
||||||
isNewThread: !oldThreadShouldBeVisible,
|
isNewThread: !oldThreadShouldBeVisible,
|
||||||
|
@ -332,21 +331,14 @@ extension ConversationVC:
|
||||||
let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft,
|
let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft,
|
||||||
(try? interaction.linkPreview.isEmpty(db)) == true
|
(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(
|
try LinkPreview(
|
||||||
url: linkPreviewDraft.urlString,
|
url: linkPreviewDraft.urlString,
|
||||||
title: linkPreviewDraft.title,
|
title: linkPreviewDraft.title,
|
||||||
attachmentId: attachmentId
|
attachmentId: LinkPreview.saveAttachmentIfPossible(
|
||||||
|
db,
|
||||||
|
imageData: linkPreviewDraft.jpegImageData,
|
||||||
|
mimeType: OWSMimeTypeImageJpeg
|
||||||
|
)
|
||||||
).insert(db)
|
).insert(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -359,14 +351,13 @@ extension ConversationVC:
|
||||||
authorId: quoteModel.authorId,
|
authorId: quoteModel.authorId,
|
||||||
timestampMs: quoteModel.timestampMs,
|
timestampMs: quoteModel.timestampMs,
|
||||||
body: quoteModel.body,
|
body: quoteModel.body,
|
||||||
attachmentId: quoteModel.attachment?.id
|
attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
|
||||||
).insert(db)
|
).insert(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
try MessageSender.send(
|
try MessageSender.send(
|
||||||
db,
|
db,
|
||||||
interaction: interaction,
|
interaction: interaction,
|
||||||
with: [],
|
|
||||||
in: thread
|
in: thread
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
@ -417,14 +408,14 @@ extension ConversationVC:
|
||||||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||||
|
|
||||||
// Create the interaction
|
// Create the interaction
|
||||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
let interaction: Interaction = try Interaction(
|
let interaction: Interaction = try Interaction(
|
||||||
threadId: thread.id,
|
threadId: thread.id,
|
||||||
authorId: getUserHexEncodedPublicKey(db),
|
authorId: getUserHexEncodedPublicKey(db),
|
||||||
variant: .standardOutgoing,
|
variant: .standardOutgoing,
|
||||||
body: text,
|
body: text,
|
||||||
timestampMs: sentTimestampMs,
|
timestampMs: sentTimestampMs,
|
||||||
hasMention: text.contains("@\(currentUserPublicKey)")
|
hasMention: text.contains("@\(userPublicKey)")
|
||||||
).inserted(db)
|
).inserted(db)
|
||||||
|
|
||||||
try MessageSender.send(
|
try MessageSender.send(
|
||||||
|
@ -668,33 +659,41 @@ extension ConversationVC:
|
||||||
case .audio: viewModel.playOrPauseAudio(for: item)
|
case .audio: viewModel.playOrPauseAudio(for: item)
|
||||||
|
|
||||||
case .mediaMessage:
|
case .mediaMessage:
|
||||||
guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
|
guard
|
||||||
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return }
|
let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }),
|
||||||
if
|
let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
|
||||||
viewItem.interaction is TSIncomingMessage,
|
let albumView: MediaAlbumView = cell.albumView
|
||||||
let thread = self.thread as? TSContactThread,
|
else { return }
|
||||||
let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }),
|
|
||||||
contact?.isTrusted != true {
|
let locationInCell: CGPoint = gestureRecognizer.location(in: cell)
|
||||||
confirmDownload()
|
|
||||||
} else {
|
// Figure out which of the media views was tapped
|
||||||
guard let albumView = cell.albumView else { return }
|
let locationInAlbumView: CGPoint = cell.convert(locationInCell, to: albumView)
|
||||||
let locationInCell = gestureRecognizer.location(in: cell)
|
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
|
||||||
// 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 }
|
switch mediaView.attachment.state {
|
||||||
if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
|
case .pending, .downloading, .uploading:
|
||||||
// TODO: Tapped a failed incoming attachment
|
// TODO: Tapped a failed incoming attachment
|
||||||
}
|
break
|
||||||
let attachment = mediaView.attachment
|
|
||||||
if let pointer = attachment as? TSAttachmentPointer {
|
case .failed:
|
||||||
if pointer.state == .failed {
|
// TODO: Tapped a failed incoming attachment
|
||||||
// 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:
|
case .genericAttachment:
|
||||||
guard
|
guard
|
||||||
let attachment: Attachment = item.attachments?.first,
|
let attachment: Attachment = item.attachments?.first,
|
||||||
|
@ -1554,9 +1553,9 @@ extension ConversationVC {
|
||||||
alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in
|
alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in
|
||||||
// Delete the request
|
// Delete the request
|
||||||
GRDBStorage.shared.writeAsync(
|
GRDBStorage.shared.writeAsync(
|
||||||
updates: { [weak self] db in
|
updates: { db in
|
||||||
// Update the contact
|
// Update the contact
|
||||||
try? Contact
|
_ = try Contact
|
||||||
.fetchOrCreate(db, id: threadId)
|
.fetchOrCreate(db, id: threadId)
|
||||||
.with(
|
.with(
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
|
@ -1586,3 +1585,98 @@ extension ConversationVC {
|
||||||
self.present(alertVC, animated: true, completion: nil)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
@objc(OWSMediaAlbumView)
|
|
||||||
public class MediaAlbumView: UIStackView {
|
public class MediaAlbumView: UIStackView {
|
||||||
private let items: [ConversationMediaAlbumItem]
|
private let items: [Attachment]
|
||||||
|
|
||||||
@objc
|
|
||||||
public let itemViews: [MediaView]
|
public let itemViews: [MediaView]
|
||||||
|
|
||||||
@objc
|
|
||||||
public var moreItemsView: MediaView?
|
public var moreItemsView: MediaView?
|
||||||
|
|
||||||
private static let kSpacingPts: CGFloat = 2
|
private static let kSpacingPts: CGFloat = 2
|
||||||
|
@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView {
|
||||||
notImplemented()
|
notImplemented()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
public required init(
|
||||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
items: [ConversationMediaAlbumItem],
|
items: [Attachment],
|
||||||
isOutgoing: Bool,
|
isOutgoing: Bool,
|
||||||
maxMessageWidth: CGFloat) {
|
maxMessageWidth: CGFloat
|
||||||
|
) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
|
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||||
let result = MediaView(mediaCache: mediaCache,
|
.map {
|
||||||
attachment: $0.attachment,
|
MediaView(
|
||||||
isOutgoing: isOutgoing,
|
mediaCache: mediaCache,
|
||||||
maxMessageWidth: maxMessageWidth)
|
attachment: $0,
|
||||||
return result
|
isOutgoing: isOutgoing,
|
||||||
}
|
maxMessageWidth: maxMessageWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView {
|
||||||
|
|
||||||
private func createContents(maxMessageWidth: CGFloat) {
|
private func createContents(maxMessageWidth: CGFloat) {
|
||||||
switch itemViews.count {
|
switch itemViews.count {
|
||||||
case 0:
|
case 0: return owsFailDebug("No item views.")
|
||||||
owsFailDebug("No item views.")
|
|
||||||
return
|
case 1:
|
||||||
case 1:
|
// X
|
||||||
// X
|
guard let itemView = itemViews.first else {
|
||||||
guard let itemView = itemViews.first else {
|
owsFailDebug("Missing item view.")
|
||||||
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")
|
|
||||||
return
|
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()
|
let rightViews = Array(itemViews[1..<3])
|
||||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
addArrangedSubview(
|
||||||
lastView.addSubview(tintView)
|
newRow(
|
||||||
tintView.autoPinEdgesToSuperviewEdges()
|
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 topViews = Array(itemViews[0..<2])
|
||||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
addArrangedSubview(
|
||||||
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
|
newRow(
|
||||||
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText)
|
rowViews: topViews,
|
||||||
let moreLabel = UILabel()
|
axis: .horizontal,
|
||||||
moreLabel.text = moreText
|
viewSize: imageSize
|
||||||
moreLabel.textColor = UIColor.ows_white
|
)
|
||||||
// We don't want to use dynamic text here.
|
)
|
||||||
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
|
||||||
lastView.addSubview(moreLabel)
|
let bottomViews = Array(itemViews[2..<4])
|
||||||
moreLabel.autoCenterInSuperview()
|
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 {
|
for itemView in itemViews {
|
||||||
|
@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoSet(viewSize: CGFloat,
|
private func autoSet(
|
||||||
ofViews views: [MediaView]) {
|
viewSize: CGFloat,
|
||||||
|
ofViews views: [MediaView]
|
||||||
|
) {
|
||||||
for itemView in views {
|
for itemView in views {
|
||||||
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func newRow(rowViews: [MediaView],
|
private func newRow(
|
||||||
axis: NSLayoutConstraint.Axis,
|
rowViews: [MediaView],
|
||||||
viewSize: CGFloat) -> UIStackView {
|
axis: NSLayoutConstraint.Axis,
|
||||||
|
viewSize: CGFloat
|
||||||
|
) -> UIStackView {
|
||||||
autoSet(viewSize: viewSize, ofViews: rowViews)
|
autoSet(viewSize: viewSize, ofViews: rowViews)
|
||||||
return newRow(rowViews: rowViews, axis: axis)
|
return newRow(rowViews: rowViews, axis: axis)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func newRow(rowViews: [MediaView],
|
private func newRow(
|
||||||
axis: NSLayoutConstraint.Axis) -> UIStackView {
|
rowViews: [MediaView],
|
||||||
|
axis: NSLayoutConstraint.Axis
|
||||||
|
) -> UIStackView {
|
||||||
let stackView = UIStackView(arrangedSubviews: rowViews)
|
let stackView = UIStackView(arrangedSubviews: rowViews)
|
||||||
stackView.axis = axis
|
stackView.axis = axis
|
||||||
stackView.spacing = MediaAlbumView.kSpacingPts
|
stackView.spacing = MediaAlbumView.kSpacingPts
|
||||||
return stackView
|
return stackView
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func loadMedia() {
|
public func loadMedia() {
|
||||||
for itemView in itemViews {
|
for itemView in itemViews {
|
||||||
itemView.loadMedia()
|
itemView.loadMedia()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func unloadMedia() {
|
public func unloadMedia() {
|
||||||
for itemView in itemViews {
|
for itemView in itemViews {
|
||||||
itemView.unloadMedia()
|
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
|
// TODO: Unless design changes, we want to display
|
||||||
// items which are still downloading and invalid
|
// items which are still downloading and invalid
|
||||||
// items.
|
// items.
|
||||||
|
@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView {
|
||||||
return validItems
|
return validItems
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
public class func layoutSize(
|
||||||
public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat,
|
forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||||
items: [ConversationMediaAlbumItem]) -> CGSize {
|
items: [Attachment]
|
||||||
|
) -> CGSize {
|
||||||
let itemCount = itemsToDisplay(forItems: items).count
|
let itemCount = itemsToDisplay(forItems: items).count
|
||||||
|
|
||||||
switch itemCount {
|
switch itemCount {
|
||||||
case 0, 1, 4:
|
case 0, 1, 4:
|
||||||
// X
|
// X
|
||||||
//
|
//
|
||||||
// or
|
// or
|
||||||
//
|
//
|
||||||
// XX
|
// XX
|
||||||
// XX
|
// XX
|
||||||
// Square
|
// Square
|
||||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||||
case 2:
|
|
||||||
// X X
|
case 2:
|
||||||
// side-by-side.
|
// X X
|
||||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
// side-by-side.
|
||||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||||
case 3:
|
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||||
// x
|
|
||||||
// X x
|
case 3:
|
||||||
// Big on left, 2 small on right.
|
// x
|
||||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
// X x
|
||||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
// Big on left, 2 small on right.
|
||||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||||
default:
|
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||||
// X X
|
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||||
// xxx
|
|
||||||
// 2 big on top, 3 small on bottom.
|
default:
|
||||||
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
// X X
|
||||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
// xxx
|
||||||
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
// 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? {
|
public func mediaView(forLocation location: CGPoint) -> MediaView? {
|
||||||
var bestMediaView: MediaView?
|
var bestMediaView: MediaView?
|
||||||
var bestDistance: CGFloat = 0
|
var bestDistance: CGFloat = 0
|
||||||
|
@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView {
|
||||||
return bestMediaView
|
return bestMediaView
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
||||||
return moreItemsView == mediaView
|
return moreItemsView == mediaView
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class MediaPlaceholderView : UIView {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem
|
import SessionMessagingKit
|
||||||
private let textColor: UIColor
|
|
||||||
|
final class MediaPlaceholderView: UIView {
|
||||||
// MARK: Settings
|
|
||||||
private static let iconSize: CGFloat = 24
|
private static let iconSize: CGFloat = 24
|
||||||
private static let iconImageViewSize: CGFloat = 40
|
private static let iconImageViewSize: CGFloat = 40
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
|
||||||
self.viewItem = viewItem
|
init(item: ConversationViewModel.Item, textColor: UIColor) {
|
||||||
self.textColor = textColor
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(item: item, textColor: textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
|
||||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(
|
||||||
|
item: ConversationViewModel.Item,
|
||||||
|
textColor: UIColor
|
||||||
|
) {
|
||||||
let (iconName, attachmentDescription): (String, String) = {
|
let (iconName, attachmentDescription): (String, String) = {
|
||||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
|
guard
|
||||||
var attachments: [TSAttachment] = []
|
item.interactionVariant == .standardIncoming,
|
||||||
Storage.read { transaction in
|
let attachment: Attachment = item.attachments?.first
|
||||||
attachments = message.attachments(with: transaction)
|
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 attachment.isAudio { return ("attachment_audio", "audio") }
|
||||||
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
|
if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
|
||||||
|
|
||||||
return ("actionsheet_document_black", "file")
|
return ("actionsheet_document_black", "file")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Image view
|
// Image view
|
||||||
let iconSize = MediaPlaceholderView.iconSize
|
let imageView = UIImageView(
|
||||||
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
image: UIImage(named: iconName)?
|
||||||
let imageView = UIImageView(image: icon)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
.resizedImage(
|
||||||
|
to: CGSize(
|
||||||
|
width: MediaPlaceholderView.iconSize,
|
||||||
|
height: MediaPlaceholderView.iconSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
imageView.tintColor = textColor
|
||||||
imageView.contentMode = .center
|
imageView.contentMode = .center
|
||||||
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
|
imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
|
||||||
imageView.set(.width, to: iconImageViewSize)
|
imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
|
||||||
imageView.set(.height, to: iconImageViewSize)
|
|
||||||
// Body label
|
// Body label
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||||
titleLabel.textColor = textColor
|
titleLabel.textColor = textColor
|
||||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
import YYImage
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
@objc(OWSMediaView)
|
|
||||||
public class MediaView: UIView {
|
public class MediaView: UIView {
|
||||||
|
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||||
|
|
||||||
private enum MediaError {
|
private enum MediaError {
|
||||||
case missing
|
case missing
|
||||||
case invalid
|
case invalid
|
||||||
|
@ -17,8 +17,7 @@ public class MediaView: UIView {
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
private let mediaCache: NSCache<NSString, AnyObject>
|
private let mediaCache: NSCache<NSString, AnyObject>
|
||||||
@objc
|
public let attachment: Attachment
|
||||||
public let attachment: TSAttachment
|
|
||||||
private let isOutgoing: Bool
|
private let isOutgoing: Bool
|
||||||
private let maxMessageWidth: CGFloat
|
private let maxMessageWidth: CGFloat
|
||||||
private var loadBlock: (() -> Void)?
|
private var loadBlock: (() -> Void)?
|
||||||
|
@ -42,50 +41,16 @@ public class MediaView: UIView {
|
||||||
case failed
|
case failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thread-safe access to load state.
|
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initializers
|
// MARK: - Initializers
|
||||||
|
|
||||||
@objc
|
public required init(
|
||||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
attachment: TSAttachment,
|
attachment: Attachment,
|
||||||
isOutgoing: Bool,
|
isOutgoing: Bool,
|
||||||
maxMessageWidth: CGFloat) {
|
maxMessageWidth: CGFloat
|
||||||
|
) {
|
||||||
self.mediaCache = mediaCache
|
self.mediaCache = mediaCache
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.isOutgoing = isOutgoing
|
self.isOutgoing = isOutgoing
|
||||||
|
@ -105,9 +70,7 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
AssertIsOnMainThread()
|
loadState.mutate { $0 = .unloaded }
|
||||||
|
|
||||||
loadState = .unloaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
@ -115,41 +78,41 @@ public class MediaView: UIView {
|
||||||
private func createContents() {
|
private func createContents() {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
guard attachment.state == .uploaded || attachment.state == .downloaded else {
|
||||||
addDownloadProgressIfNecessary()
|
addDownloadProgressIfNecessary()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !isFailedDownload else {
|
guard attachment.state != .failed else {
|
||||||
configure(forError: .failed)
|
configure(forError: .failed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if attachmentStream.isAnimated {
|
|
||||||
configureForAnimatedImage(attachmentStream: attachmentStream)
|
if attachment.isAnimated {
|
||||||
} else if attachmentStream.isImage {
|
configureForAnimatedImage(attachment: attachment)
|
||||||
configureForStillImage(attachmentStream: attachmentStream)
|
}
|
||||||
} else if attachmentStream.isVideo {
|
else if attachment.isImage {
|
||||||
configureForVideo(attachmentStream: attachmentStream)
|
configureForStillImage(attachment: attachment)
|
||||||
} else {
|
}
|
||||||
|
else if attachment.isVideo {
|
||||||
|
configureForVideo(attachment: attachment)
|
||||||
|
}
|
||||||
|
else {
|
||||||
owsFailDebug("Attachment has unexpected type.")
|
owsFailDebug("Attachment has unexpected type.")
|
||||||
configure(forError: .invalid)
|
configure(forError: .invalid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addDownloadProgressIfNecessary() {
|
private func addDownloadProgressIfNecessary() {
|
||||||
guard !isFailedDownload else {
|
guard attachment.state != .failed else {
|
||||||
configure(forError: .failed)
|
configure(forError: .failed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
guard attachment.state != .uploading && attachment.state != .uploaded else {
|
||||||
owsFailDebug("Attachment has unexpected type.")
|
|
||||||
configure(forError: .invalid)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard attachmentPointer.pointerType == .incoming else {
|
|
||||||
// TODO: Show "restoring" indicator and possibly progress.
|
// TODO: Show "restoring" indicator and possibly progress.
|
||||||
configure(forError: .missing)
|
configure(forError: .missing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||||
let loader = MediaLoaderView()
|
let loader = MediaLoaderView()
|
||||||
addSubview(loader)
|
addSubview(loader)
|
||||||
|
@ -158,23 +121,20 @@ public class MediaView: UIView {
|
||||||
|
|
||||||
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
||||||
guard isOutgoing else { return false }
|
guard isOutgoing else { return false }
|
||||||
guard let attachmentStream = attachment as? TSAttachmentStream else { return false }
|
guard attachment.state != .uploaded else { return false }
|
||||||
guard !attachmentStream.isUploaded else { return false }
|
|
||||||
let loader = MediaLoaderView()
|
let loader = MediaLoaderView()
|
||||||
addSubview(loader)
|
addSubview(loader)
|
||||||
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) {
|
private func configureForAnimatedImage(attachment: Attachment) {
|
||||||
guard let cacheKey = attachmentStream.uniqueId else {
|
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
|
||||||
owsFailDebug("Attachment stream missing unique ID.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let animatedImageView = YYAnimatedImageView()
|
|
||||||
// We need to specify a contentMode since the size of the image
|
// We need to specify a contentMode since the size of the image
|
||||||
// might not match the aspect ratio of the view.
|
// might not match the aspect ratio of the view.
|
||||||
animatedImageView.contentMode = .scaleAspectFill
|
animatedImageView.contentMode = MediaView.contentMode
|
||||||
// Use trilinear filters for better scaling quality at
|
// Use trilinear filters for better scaling quality at
|
||||||
// some performance cost.
|
// some performance cost.
|
||||||
animatedImageView.layer.minificationFilter = .trilinear
|
animatedImageView.layer.minificationFilter = .trilinear
|
||||||
|
@ -187,36 +147,37 @@ public class MediaView: UIView {
|
||||||
loadBlock = { [weak self] in
|
loadBlock = { [weak self] in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
guard let strongSelf = self else {
|
guard let strongSelf = self else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if animatedImageView.image != nil {
|
if animatedImageView.image != nil {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
strongSelf.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidImage else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else {
|
||||||
return nil
|
Logger.warn("Ignoring invalid attachment.")
|
||||||
}
|
return
|
||||||
guard let filePath = attachmentStream.originalFilePath else {
|
}
|
||||||
owsFailDebug("Attachment stream missing original file path.")
|
guard let filePath: String = attachment.originalFilePath else {
|
||||||
return nil
|
owsFailDebug("Attachment stream missing original file path.")
|
||||||
}
|
return
|
||||||
let animatedImage = YYImage(contentsOfFile: filePath)
|
}
|
||||||
return animatedImage
|
|
||||||
},
|
applyMediaBlock(YYImage(contentsOfFile: filePath))
|
||||||
applyMediaBlock: { (media) in
|
},
|
||||||
AssertIsOnMainThread()
|
applyMediaBlock: { media in
|
||||||
|
AssertIsOnMainThread()
|
||||||
guard let image = media as? YYImage else {
|
|
||||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
guard let image: YYImage = media as? YYImage else {
|
||||||
return
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
}
|
return
|
||||||
animatedImageView.image = image
|
}
|
||||||
},
|
|
||||||
cacheKey: cacheKey)
|
animatedImageView.image = image
|
||||||
|
},
|
||||||
|
cacheKey: attachment.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
@ -225,15 +186,11 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureForStillImage(attachmentStream: TSAttachmentStream) {
|
private func configureForStillImage(attachment: Attachment) {
|
||||||
guard let cacheKey = attachmentStream.uniqueId else {
|
|
||||||
owsFailDebug("Attachment stream missing unique ID.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let stillImageView = UIImageView()
|
let stillImageView = UIImageView()
|
||||||
// We need to specify a contentMode since the size of the image
|
// We need to specify a contentMode since the size of the image
|
||||||
// might not match the aspect ratio of the view.
|
// might not match the aspect ratio of the view.
|
||||||
stillImageView.contentMode = .scaleAspectFill
|
stillImageView.contentMode = MediaView.contentMode
|
||||||
// Use trilinear filters for better scaling quality at
|
// Use trilinear filters for better scaling quality at
|
||||||
// some performance cost.
|
// some performance cost.
|
||||||
stillImageView.layer.minificationFilter = .trilinear
|
stillImageView.layer.minificationFilter = .trilinear
|
||||||
|
@ -242,6 +199,7 @@ public class MediaView: UIView {
|
||||||
addSubview(stillImageView)
|
addSubview(stillImageView)
|
||||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||||
_ = addUploadProgressIfNecessary(stillImageView)
|
_ = addUploadProgressIfNecessary(stillImageView)
|
||||||
|
|
||||||
loadBlock = { [weak self] in
|
loadBlock = { [weak self] in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
@ -249,29 +207,31 @@ public class MediaView: UIView {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
self?.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidImage else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else {
|
||||||
return nil
|
Logger.warn("Ignoring invalid attachment.")
|
||||||
}
|
return
|
||||||
return attachmentStream.thumbnailImageLarge(success: { (image) in
|
}
|
||||||
|
|
||||||
|
attachment.thumbnail(
|
||||||
|
size: .large,
|
||||||
|
success: { image, _ in applyMediaBlock(image) },
|
||||||
|
failure: { Logger.error("Could not load thumbnail") }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
guard let image: UIImage = media as? UIImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stillImageView.image = image
|
stillImageView.image = image
|
||||||
}, failure: {
|
},
|
||||||
Logger.error("Could not load thumbnail")
|
cacheKey: attachment.id
|
||||||
})
|
)
|
||||||
},
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
@ -280,15 +240,11 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureForVideo(attachmentStream: TSAttachmentStream) {
|
private func configureForVideo(attachment: Attachment) {
|
||||||
guard let cacheKey = attachmentStream.uniqueId else {
|
|
||||||
owsFailDebug("Attachment stream missing unique ID.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let stillImageView = UIImageView()
|
let stillImageView = UIImageView()
|
||||||
// We need to specify a contentMode since the size of the image
|
// We need to specify a contentMode since the size of the image
|
||||||
// might not match the aspect ratio of the view.
|
// might not match the aspect ratio of the view.
|
||||||
stillImageView.contentMode = .scaleAspectFill
|
stillImageView.contentMode = MediaView.contentMode
|
||||||
// Use trilinear filters for better scaling quality at
|
// Use trilinear filters for better scaling quality at
|
||||||
// some performance cost.
|
// some performance cost.
|
||||||
stillImageView.layer.minificationFilter = .trilinear
|
stillImageView.layer.minificationFilter = .trilinear
|
||||||
|
@ -314,29 +270,31 @@ public class MediaView: UIView {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
self?.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidVideo else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else {
|
||||||
return nil
|
Logger.warn("Ignoring invalid attachment.")
|
||||||
}
|
return
|
||||||
return attachmentStream.thumbnailImageMedium(success: { (image) in
|
}
|
||||||
|
|
||||||
|
attachment.thumbnail(
|
||||||
|
size: .medium,
|
||||||
|
success: { image, _ in applyMediaBlock(image) },
|
||||||
|
failure: { Logger.error("Could not load thumbnail") }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
guard let image: UIImage = media as? UIImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stillImageView.image = image
|
stillImageView.image = image
|
||||||
}, failure: {
|
},
|
||||||
Logger.error("Could not load thumbnail")
|
cacheKey: attachment.id
|
||||||
})
|
)
|
||||||
},
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
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) {
|
private func configure(forError error: MediaError) {
|
||||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
|
||||||
let icon: UIImage
|
let icon: UIImage
|
||||||
|
|
||||||
switch error {
|
switch error {
|
||||||
case .failed:
|
case .failed:
|
||||||
guard let asset = UIImage(named: "media_retry") else {
|
guard let asset = UIImage(named: "media_retry") else {
|
||||||
owsFailDebug("Missing image")
|
owsFailDebug("Missing image")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
icon = asset
|
icon = asset
|
||||||
case .invalid:
|
|
||||||
guard let asset = UIImage(named: "media_invalid") else {
|
case .invalid:
|
||||||
owsFailDebug("Missing image")
|
guard let asset = UIImage(named: "media_invalid") else {
|
||||||
return
|
owsFailDebug("Missing image")
|
||||||
}
|
return
|
||||||
icon = asset
|
}
|
||||||
case .missing:
|
icon = asset
|
||||||
return
|
|
||||||
|
case .missing: return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||||
|
|
||||||
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||||
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||||
addSubview(iconView)
|
addSubview(iconView)
|
||||||
iconView.autoCenterInSuperview()
|
iconView.autoCenterInSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?,
|
private func tryToLoadMedia(
|
||||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
|
||||||
cacheKey: String) {
|
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||||
AssertIsOnMainThread()
|
cacheKey: String
|
||||||
|
) {
|
||||||
// It's critical that we update loadState once
|
// It's critical that we update loadState once
|
||||||
// our load attempt is complete.
|
// our load attempt is complete.
|
||||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in
|
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
|
||||||
AssertIsOnMainThread()
|
guard self?.loadState.wrappedValue == .loading else {
|
||||||
|
|
||||||
guard let strongSelf = self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard strongSelf.loadState == .loading else {
|
|
||||||
Logger.verbose("Skipping obsolete load.")
|
Logger.verbose("Skipping obsolete load.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let media = possibleMedia else {
|
guard let media: AnyObject = possibleMedia else {
|
||||||
strongSelf.loadState = .failed
|
self?.loadState.mutate { $0 = .failed }
|
||||||
// TODO:
|
// TODO:
|
||||||
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMediaBlock(media)
|
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)")
|
owsFailDebug("Unexpected load state: \(loadState)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaCache = self.mediaCache
|
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
|
||||||
if let media = mediaCache.object(forKey: cacheKey as NSString) {
|
|
||||||
Logger.verbose("media cache hit")
|
Logger.verbose("media cache hit")
|
||||||
|
|
||||||
|
guard !Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
loadMediaBlock(loadCompletion)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loadCompletion(media)
|
loadCompletion(media)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.verbose("media cache miss")
|
Logger.verbose("media cache miss")
|
||||||
|
|
||||||
let threadSafeLoadState = self.threadSafeLoadState
|
MediaView.loadQueue.async { [weak self] in
|
||||||
MediaView.loadQueue.async {
|
guard self?.loadState.wrappedValue == .loading else {
|
||||||
guard threadSafeLoadState.get() == .loading else {
|
|
||||||
Logger.verbose("Skipping obsolete load.")
|
Logger.verbose("Skipping obsolete load.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let media = loadMediaBlock() else {
|
|
||||||
Logger.error("Failed to load media.")
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
loadCompletion(nil)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
mediaCache.setObject(media, forKey: cacheKey as NSString)
|
loadMediaBlock(loadCompletion)
|
||||||
|
|
||||||
loadCompletion(media)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -459,32 +405,18 @@ public class MediaView: UIView {
|
||||||
// "skip rate" of obsolete loads.
|
// "skip rate" of obsolete loads.
|
||||||
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
||||||
|
|
||||||
@objc
|
|
||||||
public func loadMedia() {
|
public func loadMedia() {
|
||||||
AssertIsOnMainThread()
|
switch loadState.wrappedValue {
|
||||||
|
case .unloaded:
|
||||||
switch loadState {
|
loadState.mutate { $0 = .loading }
|
||||||
case .unloaded:
|
loadBlock?()
|
||||||
loadState = .loading
|
|
||||||
|
case .loading, .loaded, .failed: break
|
||||||
guard let loadBlock = loadBlock else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadBlock()
|
|
||||||
case .loading, .loaded, .failed:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func unloadMedia() {
|
public func unloadMedia() {
|
||||||
AssertIsOnMainThread()
|
loadState.mutate { $0 = .unloaded }
|
||||||
|
unloadBlock?()
|
||||||
loadState = .unloaded
|
|
||||||
|
|
||||||
guard let unloadBlock = unloadBlock else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unloadBlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,7 +135,7 @@ final class QuoteView: UIView {
|
||||||
|
|
||||||
attachment.thumbnail(
|
attachment.thumbnail(
|
||||||
size: .small,
|
size: .small,
|
||||||
success: { image in
|
success: { image, _ in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
imageView.image = image
|
imageView.image = image
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -767,7 +767,7 @@ CGFloat kIconViewLength = 24;
|
||||||
- (void)leaveGroup
|
- (void)leaveGroup
|
||||||
{
|
{
|
||||||
if (self.isClosedGroup) {
|
if (self.isClosedGroup) {
|
||||||
[[SNMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
[[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
||||||
}
|
}
|
||||||
|
|
||||||
[self.navigationController popViewControllerAnimated:YES];
|
[self.navigationController popViewControllerAnimated:YES];
|
||||||
|
@ -818,7 +818,7 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
// If we successfully blocked then force a config sync
|
// If we successfully blocked then force a config sync
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
[SNMessageSender forceSyncConfigurationNow];
|
[SMKMessageSender forceSyncConfigurationNow];
|
||||||
}
|
}
|
||||||
|
|
||||||
[weakSelf updateTableContents];
|
[weakSelf updateTableContents];
|
||||||
|
@ -837,7 +837,7 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
// If we successfully unblocked then force a config sync
|
// If we successfully unblocked then force a config sync
|
||||||
if (!isBlocked) {
|
if (!isBlocked) {
|
||||||
[SNMessageSender forceSyncConfigurationNow];
|
[SMKMessageSender forceSyncConfigurationNow];
|
||||||
}
|
}
|
||||||
|
|
||||||
[weakSelf updateTableContents];
|
[weakSelf updateTableContents];
|
||||||
|
@ -900,12 +900,8 @@ CGFloat kIconViewLength = 24;
|
||||||
{
|
{
|
||||||
OWSLogDebug(@"");
|
OWSLogDebug(@"");
|
||||||
|
|
||||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup];
|
|
||||||
|
|
||||||
self.mediaGallery = mediaGallery;
|
|
||||||
|
|
||||||
OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]);
|
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
|
- (void)tappedConversationSearch
|
||||||
|
|
|
@ -54,7 +54,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
return
|
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
|
// ensure images at the end of the list can be scrolled above the bottom buttons
|
||||||
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
|
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
|
||||||
|
@ -543,11 +543,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
return UICollectionViewCell(forAutoLayout: ())
|
return UICollectionViewCell(forAutoLayout: ())
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||||
owsFail("cell was unexpectedly nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
||||||
|
|
||||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||||
cell.configure(item: assetItem)
|
cell.configure(item: assetItem)
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,58 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SessionUIKit
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
public protocol MediaTileViewControllerDelegate: class {
|
public protocol MediaTileViewControllerDelegate: AnyObject {
|
||||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem)
|
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryViewModel.Item)
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout {
|
public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout {
|
||||||
|
|
|
@ -16,9 +16,6 @@ public protocol PhotoGridItem: AnyObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PhotoGridViewCell: UICollectionViewCell {
|
public class PhotoGridViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
static let reuseIdentifier = "PhotoGridViewCell"
|
|
||||||
|
|
||||||
public let imageView: UIImageView
|
public let imageView: UIImageView
|
||||||
|
|
||||||
private let contentTypeBadgeView: UIImageView
|
private let contentTypeBadgeView: UIImageView
|
||||||
|
@ -128,7 +125,9 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
||||||
Logger.debug("image == nil")
|
Logger.debug("image == nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.image = image
|
DispatchQueue.main.async {
|
||||||
|
self?.image = image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch item.type {
|
switch item.type {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)?
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
44
Session/Utilities/UINavigationBar+Utilities.swift
Normal file
44
Session/Utilities/UINavigationBar+Utilities.swift
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1110,7 +1110,7 @@ public enum Legacy {
|
||||||
internal final class _AttachmentUploadJob: NSObject, NSCoding {
|
internal final class _AttachmentUploadJob: NSObject, NSCoding {
|
||||||
internal let attachmentID: String
|
internal let attachmentID: String
|
||||||
internal let threadID: String
|
internal let threadID: String
|
||||||
internal let message: Message
|
internal let message: _Message
|
||||||
internal let messageSendJobID: String
|
internal let messageSendJobID: String
|
||||||
internal var id: String?
|
internal var id: String?
|
||||||
internal var failureCount: UInt = 0
|
internal var failureCount: UInt = 0
|
||||||
|
@ -1121,7 +1121,7 @@ public enum Legacy {
|
||||||
guard
|
guard
|
||||||
let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
|
let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
|
||||||
let threadID = coder.decodeObject(forKey: "threadID") 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 messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?,
|
||||||
let id = coder.decodeObject(forKey: "id") as! String?
|
let id = coder.decodeObject(forKey: "id") as! String?
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
|
@ -229,6 +229,9 @@ enum _001_InitialSetupMigration: Migration {
|
||||||
t.column(.width, .integer)
|
t.column(.width, .integer)
|
||||||
t.column(.height, .integer)
|
t.column(.height, .integer)
|
||||||
t.column(.duration, .double)
|
t.column(.duration, .double)
|
||||||
|
t.column(.isVisualMedia, .boolean)
|
||||||
|
.notNull()
|
||||||
|
.defaults(to: false)
|
||||||
t.column(.isValid, .boolean)
|
t.column(.isValid, .boolean)
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaults(to: false)
|
.defaults(to: false)
|
||||||
|
|
|
@ -969,7 +969,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in
|
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)
|
attachmentUploadJobs.insert(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1059,7 +1059,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
||||||
|
|
||||||
// MARK: - --messageSend
|
// MARK: - --messageSend
|
||||||
|
|
||||||
var messageSendJobIdMap: [String: Int64] = [:]
|
var messageSendJobLegacyMap: [String: Job] = [:]
|
||||||
|
|
||||||
try autoreleasepool {
|
try autoreleasepool {
|
||||||
try messageSendJobs.forEach { legacyJob in
|
try messageSendJobs.forEach { legacyJob in
|
||||||
|
@ -1132,31 +1132,42 @@ enum _003_YDBToGRDBMigration: Migration {
|
||||||
)?.inserted(db)
|
)?.inserted(db)
|
||||||
|
|
||||||
if let oldId: String = legacyJob.id, let newId: Int64 = job?.id {
|
if let oldId: String = legacyJob.id, let newId: Int64 = job?.id {
|
||||||
messageSendJobIdMap[oldId] = newId
|
messageSendJobLegacyMap[oldId] = job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - --attachmentUpload
|
// MARK: - --attachmentUpload
|
||||||
|
|
||||||
try autoreleasepool {
|
try autoreleasepool {
|
||||||
try attachmentUploadJobs.forEach { legacyJob in
|
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")
|
SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob")
|
||||||
throw GRDBStorageError.migrationFailed
|
throw GRDBStorageError.migrationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = try Job(
|
let uploadJob: Job? = try Job(
|
||||||
failureCount: legacyJob.failureCount,
|
failureCount: legacyJob.failureCount,
|
||||||
variant: .attachmentUpload,
|
variant: .attachmentUpload,
|
||||||
behaviour: .runOnce,
|
behaviour: .runOnce,
|
||||||
nextRunTimestamp: 0,
|
threadId: legacyJob.threadID,
|
||||||
|
interactionId: sendJob.interactionId,
|
||||||
details: AttachmentUploadJob.Details(
|
details: AttachmentUploadJob.Details(
|
||||||
threadId: legacyJob.threadID,
|
messageSendJobId: sendJobId,
|
||||||
attachmentId: legacyJob.attachmentID,
|
attachmentId: legacyJob.attachmentID
|
||||||
messageSendJobId: sendJobId
|
|
||||||
)
|
)
|
||||||
)?.inserted(db)
|
)?.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import AVFoundation
|
||||||
|
|
||||||
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||||
public static var databaseTableName: String { "attachment" }
|
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 quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId])
|
||||||
internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId])
|
internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId])
|
||||||
public static let interaction = hasOne(
|
public static let interaction = hasOne(
|
||||||
|
@ -36,6 +36,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
||||||
case width
|
case width
|
||||||
case height
|
case height
|
||||||
case duration
|
case duration
|
||||||
|
case isVisualMedia
|
||||||
case isValid
|
case isValid
|
||||||
case encryptionKey
|
case encryptionKey
|
||||||
case digest
|
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)
|
/// The number of seconds the attachment plays for (this will only be set for video and audio attachment types)
|
||||||
public let duration: TimeInterval?
|
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
|
/// A flag indicating whether the attachment data downloaded is valid for it's content type
|
||||||
public let isValid: Bool
|
public let isValid: Bool
|
||||||
|
|
||||||
|
@ -137,6 +141,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
||||||
width: UInt? = nil,
|
width: UInt? = nil,
|
||||||
height: UInt? = nil,
|
height: UInt? = nil,
|
||||||
duration: TimeInterval? = nil,
|
duration: TimeInterval? = nil,
|
||||||
|
isVisualMedia: Bool? = nil,
|
||||||
isValid: Bool = false,
|
isValid: Bool = false,
|
||||||
encryptionKey: Data? = nil,
|
encryptionKey: Data? = nil,
|
||||||
digest: Data? = nil,
|
digest: Data? = nil,
|
||||||
|
@ -155,6 +160,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
|
self.isVisualMedia = (isVisualMedia ?? (
|
||||||
|
MIMETypeUtil.isImage(contentType) ||
|
||||||
|
MIMETypeUtil.isVideo(contentType) ||
|
||||||
|
MIMETypeUtil.isAnimated(contentType)
|
||||||
|
))
|
||||||
self.isValid = isValid
|
self.isValid = isValid
|
||||||
self.encryptionKey = encryptionKey
|
self.encryptionKey = encryptionKey
|
||||||
self.digest = digest
|
self.digest = digest
|
||||||
|
@ -166,9 +176,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
variant: Variant = .standard,
|
variant: Variant = .standard,
|
||||||
contentType: String,
|
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
|
return nil
|
||||||
}
|
}
|
||||||
guard dataSource.write(toPath: originalFilePath) 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.contentType = contentType
|
||||||
self.byteCount = dataSource.dataLength()
|
self.byteCount = dataSource.dataLength()
|
||||||
self.creationTimestamp = nil
|
self.creationTimestamp = nil
|
||||||
self.sourceFilename = nil
|
self.sourceFilename = sourceFilename
|
||||||
self.downloadUrl = nil
|
self.downloadUrl = nil
|
||||||
self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent
|
self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent
|
||||||
self.width = imageSize.map { UInt(floor($0.width)) }
|
self.width = imageSize.map { UInt(floor($0.width)) }
|
||||||
self.height = imageSize.map { UInt(floor($0.height)) }
|
self.height = imageSize.map { UInt(floor($0.height)) }
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
|
self.isVisualMedia = (
|
||||||
|
MIMETypeUtil.isImage(contentType) ||
|
||||||
|
MIMETypeUtil.isVideo(contentType) ||
|
||||||
|
MIMETypeUtil.isAnimated(contentType)
|
||||||
|
)
|
||||||
self.isValid = isValid
|
self.isValid = isValid
|
||||||
self.encryptionKey = nil
|
self.encryptionKey = nil
|
||||||
self.digest = nil
|
self.digest = nil
|
||||||
self.caption = nil
|
self.caption = caption
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Custom Database Interaction
|
// MARK: - Custom Database Interaction
|
||||||
|
@ -309,6 +326,7 @@ public extension Attachment {
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
|
isVisualMedia: isVisualMedia,
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
encryptionKey: (encryptionKey ?? self.encryptionKey),
|
encryptionKey: (encryptionKey ?? self.encryptionKey),
|
||||||
digest: (digest ?? self.digest),
|
digest: (digest ?? self.digest),
|
||||||
|
@ -353,6 +371,11 @@ public extension Attachment {
|
||||||
self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil)
|
self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil)
|
||||||
self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil)
|
self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil)
|
||||||
self.duration = nil // Needs to be downloaded to be set
|
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.isValid = false // Needs to be downloaded to be set
|
||||||
self.encryptionKey = proto.key
|
self.encryptionKey = proto.key
|
||||||
self.digest = proto.digest
|
self.digest = proto.digest
|
||||||
|
@ -702,8 +725,6 @@ extension Attachment {
|
||||||
public var isText: Bool { MIMETypeUtil.isText(contentType) }
|
public var isText: Bool { MIMETypeUtil.isText(contentType) }
|
||||||
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
|
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
|
||||||
|
|
||||||
public var isVisualMedia: Bool { isImage || isVideo || isAnimated }
|
|
||||||
|
|
||||||
public func readDataFromFile() throws -> Data? {
|
public func readDataFromFile() throws -> Data? {
|
||||||
guard let filePath: String = self.originalFilePath else {
|
guard let filePath: String = self.originalFilePath else {
|
||||||
return nil
|
return nil
|
||||||
|
@ -716,7 +737,7 @@ extension Attachment {
|
||||||
return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg"
|
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 {
|
guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else {
|
||||||
failure()
|
failure()
|
||||||
return
|
return
|
||||||
|
@ -730,43 +751,113 @@ extension Attachment {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
success(image)
|
success(
|
||||||
|
image,
|
||||||
|
{
|
||||||
|
guard let originalFilePath: String = originalFilePath else { throw AttachmentError.invalidData }
|
||||||
|
|
||||||
|
return try Data(contentsOf: URL(fileURLWithPath: originalFilePath))
|
||||||
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumbnailPath = thumbnailPath(for: dimensions)
|
let thumbnailPath = thumbnailPath(for: dimensions)
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: thumbnailPath) {
|
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()
|
failure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
success(image)
|
success(image, { data })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
OWSThumbnailService.shared.ensureThumbnail(
|
OWSThumbnailService.shared.ensureThumbnail(
|
||||||
for: self,
|
for: self,
|
||||||
dimensions: dimensions,
|
dimensions: dimensions,
|
||||||
success: { loadedThumbnail in success(loadedThumbnail.image) },
|
success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) },
|
||||||
failure: { _ in failure() }
|
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)
|
loadThumbnail(with: size.dimension, success: success, failure: failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func cloneAsThumbnail() -> Attachment {
|
public func cloneAsThumbnail() -> Attachment? {
|
||||||
fatalError("TODO: Add this back")
|
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 {
|
public func write(data: Data) throws -> Bool {
|
||||||
guard let originalFilePath: String = originalFilePath else { return false }
|
guard let originalFilePath: String = originalFilePath else { return false }
|
||||||
|
|
||||||
try data.write(to: URL(fileURLWithPath: originalFilePath))
|
try data.write(to: URL(fileURLWithPath: originalFilePath))
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -873,6 +964,10 @@ extension Attachment {
|
||||||
.with(
|
.with(
|
||||||
serverId: "\(fileId)",
|
serverId: "\(fileId)",
|
||||||
state: .uploaded,
|
state: .uploaded,
|
||||||
|
creationTimestamp: (
|
||||||
|
updatedAttachment?.creationTimestamp ??
|
||||||
|
Date().timeIntervalSince1970
|
||||||
|
),
|
||||||
downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)"
|
downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)"
|
||||||
)
|
)
|
||||||
.saved(db)
|
.saved(db)
|
||||||
|
|
|
@ -8,7 +8,7 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord
|
||||||
public static var databaseTableName: String { "interactionAttachment" }
|
public static var databaseTableName: String { "interactionAttachment" }
|
||||||
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
|
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 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)
|
internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey)
|
||||||
|
|
||||||
public typealias Columns = CodingKeys
|
public typealias Columns = CodingKeys
|
||||||
|
|
|
@ -154,7 +154,7 @@ public extension LinkPreview {
|
||||||
return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution)
|
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 imageData: Data = imageData, !imageData.isEmpty else { return nil }
|
||||||
guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil }
|
guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil }
|
||||||
|
|
||||||
|
|
|
@ -106,6 +106,7 @@ public extension Quote {
|
||||||
quote.id != 0,
|
quote.id != 0,
|
||||||
!quote.author.isEmpty
|
!quote.author.isEmpty
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
self.interactionId = interactionId
|
self.interactionId = interactionId
|
||||||
self.timestampMs = Int64(quote.id)
|
self.timestampMs = Int64(quote.id)
|
||||||
self.authorId = quote.author
|
self.authorId = quote.author
|
||||||
|
@ -128,27 +129,24 @@ public extension Quote {
|
||||||
}
|
}
|
||||||
|
|
||||||
// We only use the first attachment
|
// We only use the first attachment
|
||||||
if let attachment = proto.attachments.first {
|
if let attachment = quote.attachments.first(where: { $0.thumbnail != nil })?.thumbnail {
|
||||||
let thumbnailAttachment: Attachment
|
self.attachmentId = try quotedInteraction
|
||||||
|
.map { quotedInteraction -> Attachment? in
|
||||||
// We prefer deriving any thumbnail locally rather than fetching one from the network
|
// If the quotedInteraction has an attachment then try clone it
|
||||||
if let quotedInteraction: Interaction = quotedInteraction {
|
if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) {
|
||||||
if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) {
|
return attachment.cloneAsThumbnail()
|
||||||
thumbnailAttachment = 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) {
|
.defaulting(to: Attachment(proto: attachment))
|
||||||
thumbnailAttachment = linkPreviewAttachment.cloneAsThumbnail()
|
.inserted(db)
|
||||||
}
|
.id
|
||||||
else {
|
|
||||||
thumbnailAttachment = Attachment(proto: attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
thumbnailAttachment = Attachment(proto: attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
try thumbnailAttachment.save(db)
|
|
||||||
self.attachmentId = thumbnailAttachment.id
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
self.attachmentId = nil
|
self.attachmentId = nil
|
||||||
|
@ -158,6 +156,5 @@ public extension Quote {
|
||||||
if self.body == nil && self.attachmentId == nil {
|
if self.body == nil && self.attachmentId == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,7 +101,7 @@ public enum AttachmentDownloadJob: JobExecutor {
|
||||||
state: .downloaded,
|
state: .downloaded,
|
||||||
creationTimestamp: Date().timeIntervalSince1970,
|
creationTimestamp: Date().timeIntervalSince1970,
|
||||||
localRelativeFilePath: attachment.originalFilePath?
|
localRelativeFilePath: attachment.originalFilePath?
|
||||||
.substring(from: Attachment.attachmentsFolder.count)
|
.substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash
|
||||||
)
|
)
|
||||||
.save(db)
|
.save(db)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ public enum AttachmentUploadJob: JobExecutor {
|
||||||
public static var maxFailureCount: Int = 10
|
public static var maxFailureCount: Int = 10
|
||||||
public static var requiresThreadId: Bool = true
|
public static var requiresThreadId: Bool = true
|
||||||
public static let requiresInteractionId: Bool = true
|
public static let requiresInteractionId: Bool = true
|
||||||
|
|
||||||
public static func run(
|
public static func run(
|
||||||
_ job: Job,
|
_ job: Job,
|
||||||
success: @escaping (Job, Bool) -> (),
|
success: @escaping (Job, Bool) -> (),
|
||||||
|
@ -51,9 +52,18 @@ public enum AttachmentUploadJob: JobExecutor {
|
||||||
|
|
||||||
extension AttachmentUploadJob {
|
extension AttachmentUploadJob {
|
||||||
public struct Details: Codable {
|
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 let attachmentId: String
|
||||||
|
|
||||||
public init(attachmentId: String) {
|
public init(messageSendJobId: Int64, attachmentId: String) {
|
||||||
|
self.messageSendJobId = messageSendJobId
|
||||||
self.attachmentId = attachmentId
|
self.attachmentId = attachmentId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,6 +71,7 @@ public enum MessageSendJob: JobExecutor {
|
||||||
threadId: job.threadId,
|
threadId: job.threadId,
|
||||||
interactionId: interactionId,
|
interactionId: interactionId,
|
||||||
details: AttachmentUploadJob.Details(
|
details: AttachmentUploadJob.Details(
|
||||||
|
messageSendJobId: jobId,
|
||||||
attachmentId: stateInfo.attachmentId
|
attachmentId: stateInfo.attachmentId
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
|
@ -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.")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -476,15 +476,6 @@ extension MessageSender {
|
||||||
return promise
|
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
|
/// 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
|
/// 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.
|
/// message). The admin can then truly remove them later.
|
||||||
|
|
|
@ -11,8 +11,8 @@ extension MessageSender {
|
||||||
|
|
||||||
public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws {
|
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 }
|
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(
|
send(
|
||||||
db,
|
db,
|
||||||
message: VisibleMessage.from(db, interaction: interaction),
|
message: VisibleMessage.from(db, interaction: interaction),
|
||||||
|
@ -175,12 +175,3 @@ extension MessageSender {
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MessageSender {
|
|
||||||
@objc(forceSyncConfigurationNow)
|
|
||||||
public static func objc_forceSyncConfigurationNow() {
|
|
||||||
GRDBStorage.shared.write { db in
|
|
||||||
try syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,63 +6,36 @@ import PromiseKit
|
||||||
import SessionSnodeKit
|
import SessionSnodeKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
@objc(SNMessageSender)
|
public final class MessageSender {
|
||||||
public final class MessageSender : NSObject {
|
|
||||||
// MARK: Initialization
|
|
||||||
private override init() { }
|
|
||||||
|
|
||||||
// MARK: - Preparation
|
// MARK: - Preparation
|
||||||
|
|
||||||
public static func prep(
|
public static func prep(
|
||||||
_ db: Database,
|
_ db: Database,
|
||||||
signalAttachments: [SignalAttachment],
|
signalAttachments: [SignalAttachment],
|
||||||
for message: VisibleMessage
|
for interactionId: Int64
|
||||||
) {
|
) throws {
|
||||||
guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else {
|
try signalAttachments.forEach { signalAttachment in
|
||||||
#if DEBUG
|
let maybeAttachment: Attachment? = Attachment(
|
||||||
preconditionFailure()
|
variant: (signalAttachment.isVoiceMessage ?
|
||||||
#else
|
.voiceMessage :
|
||||||
return
|
.standard
|
||||||
#endif
|
),
|
||||||
|
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
|
// MARK: - Convenience
|
||||||
|
@ -559,3 +532,26 @@ public final class MessageSender : NSObject {
|
||||||
return nil
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,9 @@ import GRDB
|
||||||
public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
|
public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
|
||||||
public static var databaseTableName: String { "job" }
|
public static var databaseTableName: String { "job" }
|
||||||
internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId])
|
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 dependencies = hasMany(Job.self, using: dependencyForeignKey)
|
||||||
|
internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey)
|
||||||
|
|
||||||
public typealias Columns = CodingKeys
|
public typealias Columns = CodingKeys
|
||||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||||
|
@ -153,6 +155,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
|
||||||
request(for: Job.dependencies)
|
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<Job> {
|
||||||
|
request(for: Job.dependantJobs)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
fileprivate init(
|
fileprivate init(
|
||||||
|
@ -225,7 +235,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
|
||||||
|
|
||||||
public func delete(_ db: Database) throws -> Bool {
|
public func delete(_ db: Database) throws -> Bool {
|
||||||
// Delete any dependencies
|
// Delete any dependencies
|
||||||
try dependencies
|
try dependantJobs
|
||||||
.deleteAll(db)
|
.deleteAll(db)
|
||||||
|
|
||||||
return try performDelete(db)
|
return try performDelete(db)
|
||||||
|
|
|
@ -12,5 +12,6 @@ public extension ReusableView where Self: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension UICollectionReusableView: ReusableView {}
|
||||||
extension UITableViewCell: ReusableView {}
|
extension UITableViewCell: ReusableView {}
|
||||||
extension UITableViewHeaderFooterView: ReusableView {}
|
extension UITableViewHeaderFooterView: ReusableView {}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public extension UICollectionView {
|
||||||
|
func register<View>(view: View.Type) where View: UICollectionViewCell {
|
||||||
|
register(view.self, forCellWithReuseIdentifier: view.defaultReuseIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func register<View>(view: View.Type, ofKind kind: String) where View: UICollectionReusableView {
|
||||||
|
register(view.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: view.defaultReuseIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dequeue<T>(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<T>(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
|
||||||
|
}
|
||||||
|
}
|
|
@ -524,6 +524,7 @@ public final class JobRunner {
|
||||||
GRDBStorage.shared.write { db in
|
GRDBStorage.shared.write { db in
|
||||||
// Get the max failure count for the job (a value of '-1' means it will retry indefinitely)
|
// 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 maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0)
|
||||||
|
let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + getRetryInterval(for: job))
|
||||||
|
|
||||||
guard
|
guard
|
||||||
!permanentFailure &&
|
!permanentFailure &&
|
||||||
|
@ -537,12 +538,36 @@ public final class JobRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))")
|
SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))")
|
||||||
|
|
||||||
_ = try job
|
_ = try job
|
||||||
.with(
|
.with(
|
||||||
failureCount: (job.failureCount + 1),
|
failureCount: (job.failureCount + 1),
|
||||||
nextRunTimestamp: (Date().timeIntervalSince1970 + getRetryInterval(for: job))
|
nextRunTimestamp: nextRunTimestamp
|
||||||
)
|
)
|
||||||
.saved(db)
|
.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) }
|
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
|
||||||
|
|
|
@ -5,11 +5,11 @@
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
|
||||||
public protocol GalleryRailItemProvider: AnyObject {
|
public protocol GalleryRailItemProvider {
|
||||||
var railItems: [GalleryRailItem] { get }
|
var railItems: [GalleryRailItem] { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol GalleryRailItem: AnyObject {
|
public protocol GalleryRailItem {
|
||||||
func buildRailItemView() -> UIView
|
func buildRailItemView() -> UIView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue