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, ); }; };
|
||||
B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; };
|
||||
B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; };
|
||||
B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */; };
|
||||
B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */; };
|
||||
B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; };
|
||||
B88FA7FB26114EA70049422F /* Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7FA26114EA70049422F /* Hex.swift */; };
|
||||
|
@ -750,6 +749,7 @@
|
|||
FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; };
|
||||
FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; };
|
||||
FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacyModels.swift */; };
|
||||
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; };
|
||||
FD28A4F227E990E800FF65E7 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F127E990E800FF65E7 /* BlockingManagerRemovalMigration.swift */; };
|
||||
FD28A4F427EA79F800FF65E7 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */; };
|
||||
FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; };
|
||||
|
@ -784,6 +784,7 @@
|
|||
FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; };
|
||||
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; };
|
||||
FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; };
|
||||
FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; };
|
||||
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; };
|
||||
FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; };
|
||||
FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; };
|
||||
|
@ -802,6 +803,10 @@
|
|||
FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */; };
|
||||
FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */; };
|
||||
FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; };
|
||||
FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */; };
|
||||
FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE125282D05380098B17F /* MediaInteractiveDismiss.swift */; };
|
||||
FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE127282D05530098B17F /* MediaPresentationContext.swift */; };
|
||||
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -1006,7 +1011,6 @@
|
|||
34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1798,6 +1802,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1829,6 +1834,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1849,6 +1855,10 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
@ -2080,6 +2090,7 @@
|
|||
B886B4A82398BA1500211ABE /* QRCode.swift */,
|
||||
B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */,
|
||||
B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */,
|
||||
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */,
|
||||
C31A6C59247F214E001123EF /* UIView+Glow.swift */,
|
||||
C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */,
|
||||
FD859EFF27C4691300510D0C /* MockDataGenerator.swift */,
|
||||
|
@ -2396,6 +2407,7 @@
|
|||
C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */,
|
||||
C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */,
|
||||
FD705A91278D051200F16121 /* ReusableView.swift */,
|
||||
FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */,
|
||||
FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */,
|
||||
C38EF23D255B6D66007E1867 /* UIView+OWS.h */,
|
||||
C38EF23E255B6D66007E1867 /* UIView+OWS.m */,
|
||||
|
@ -2613,7 +2625,6 @@
|
|||
C33FDB48255A580C00E217F9 /* TSOutgoingMessage.h */,
|
||||
C33FDB56255A580D00E217F9 /* TSOutgoingMessage.m */,
|
||||
B84072952565E9F50037CB17 /* TSOutgoingMessage+Conversion.swift */,
|
||||
34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */,
|
||||
);
|
||||
path = Signal;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2890,6 +2901,7 @@
|
|||
C36096BA25AD1B14008B62B2 /* Media Viewing & Editing */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FDFDE122282D04E30098B17F /* Transitions */,
|
||||
C36096B925AD1ACF008B62B2 /* GIFs */,
|
||||
34E3E5671EC4B19400495BAC /* AudioProgressView.swift */,
|
||||
346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */,
|
||||
|
@ -3755,6 +3767,17 @@
|
|||
path = Errors;
|
||||
sourceTree = "<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 */
|
||||
|
||||
/* Begin PBXHeadersBuildPhase section */
|
||||
|
@ -4749,6 +4772,7 @@
|
|||
B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */,
|
||||
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */,
|
||||
FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */,
|
||||
FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */,
|
||||
FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */,
|
||||
FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */,
|
||||
C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */,
|
||||
|
@ -4945,7 +4969,6 @@
|
|||
C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */,
|
||||
C352A30925574D8500338F3E /* Message+Destination.swift in Sources */,
|
||||
C300A5E72554B07300555489 /* ExpirationTimerUpdate.swift in Sources */,
|
||||
B88A1AC725C90A4700E6D421 /* TypingIndicatorInteraction.swift in Sources */,
|
||||
C3D9E3C025676AD70040E4F3 /* TSAttachment.m in Sources */,
|
||||
C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */,
|
||||
C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */,
|
||||
|
@ -4991,6 +5014,7 @@
|
|||
FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */,
|
||||
B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */,
|
||||
B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */,
|
||||
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */,
|
||||
452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */,
|
||||
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
||||
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
||||
|
@ -5043,9 +5067,12 @@
|
|||
4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */,
|
||||
B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */,
|
||||
B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */,
|
||||
FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */,
|
||||
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
|
||||
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
|
||||
FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */,
|
||||
FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */,
|
||||
FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */,
|
||||
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
|
||||
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
|
||||
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
|
||||
|
@ -5134,6 +5161,7 @@
|
|||
B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */,
|
||||
346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */,
|
||||
45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */,
|
||||
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */,
|
||||
C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */,
|
||||
C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */,
|
||||
B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */,
|
||||
|
|
|
@ -301,7 +301,6 @@ extension ConversationVC:
|
|||
let linkPreviewDraft: OWSLinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
|
||||
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
|
||||
|
||||
for: self.thread,
|
||||
approveMessageRequestIfNeeded(
|
||||
for: thread,
|
||||
isNewThread: !oldThreadShouldBeVisible,
|
||||
|
@ -332,21 +331,14 @@ extension ConversationVC:
|
|||
let linkPreviewDraft: OWSLinkPreviewDraft = linkPreviewDraft,
|
||||
(try? interaction.linkPreview.isEmpty(db)) == true
|
||||
{
|
||||
var attachmentId: String?
|
||||
|
||||
// If the LinkPreview has image data then create an attachment first
|
||||
if let imageData: Data = linkPreviewDraft.jpegImageData {
|
||||
attachmentId = try LinkPreview.saveAttachmentIfPossible(
|
||||
db,
|
||||
imageData: imageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
}
|
||||
|
||||
try LinkPreview(
|
||||
url: linkPreviewDraft.urlString,
|
||||
title: linkPreviewDraft.title,
|
||||
attachmentId: attachmentId
|
||||
attachmentId: LinkPreview.saveAttachmentIfPossible(
|
||||
db,
|
||||
imageData: linkPreviewDraft.jpegImageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
|
@ -359,14 +351,13 @@ extension ConversationVC:
|
|||
authorId: quoteModel.authorId,
|
||||
timestampMs: quoteModel.timestampMs,
|
||||
body: quoteModel.body,
|
||||
attachmentId: quoteModel.attachment?.id
|
||||
attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
with: [],
|
||||
in: thread
|
||||
)
|
||||
},
|
||||
|
@ -417,14 +408,14 @@ extension ConversationVC:
|
|||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
|
||||
// Create the interaction
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: thread.id,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
hasMention: text.contains("@\(currentUserPublicKey)")
|
||||
hasMention: text.contains("@\(userPublicKey)")
|
||||
).inserted(db)
|
||||
|
||||
try MessageSender.send(
|
||||
|
@ -668,33 +659,41 @@ extension ConversationVC:
|
|||
case .audio: viewModel.playOrPauseAudio(for: item)
|
||||
|
||||
case .mediaMessage:
|
||||
guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
|
||||
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return }
|
||||
if
|
||||
viewItem.interaction is TSIncomingMessage,
|
||||
let thread = self.thread as? TSContactThread,
|
||||
let contact: Contact? = GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: thread.contactSessionID()) }),
|
||||
contact?.isTrusted != true {
|
||||
confirmDownload()
|
||||
} else {
|
||||
guard let albumView = cell.albumView else { return }
|
||||
let locationInCell = gestureRecognizer.location(in: cell)
|
||||
// Figure out which of the media views was tapped
|
||||
let locationInAlbumView = cell.convert(locationInCell, to: albumView)
|
||||
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
|
||||
if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
|
||||
guard
|
||||
let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }),
|
||||
let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
|
||||
let albumView: MediaAlbumView = cell.albumView
|
||||
else { return }
|
||||
|
||||
let locationInCell: CGPoint = gestureRecognizer.location(in: cell)
|
||||
|
||||
// Figure out which of the media views was tapped
|
||||
let locationInAlbumView: CGPoint = cell.convert(locationInCell, to: albumView)
|
||||
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
|
||||
|
||||
|
||||
switch mediaView.attachment.state {
|
||||
case .pending, .downloading, .uploading:
|
||||
// TODO: Tapped a failed incoming attachment
|
||||
}
|
||||
let attachment = mediaView.attachment
|
||||
if let pointer = attachment as? TSAttachmentPointer {
|
||||
if pointer.state == .failed {
|
||||
// TODO: Tapped a failed incoming attachment
|
||||
break
|
||||
|
||||
case .failed:
|
||||
// TODO: Tapped a failed incoming attachment
|
||||
break
|
||||
|
||||
default:
|
||||
let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController(
|
||||
for: self.viewModel.viewData.thread.id,
|
||||
item: item,
|
||||
selectedAttachmentId: mediaView.attachment.id,
|
||||
options: [ .sliderEnabled, .showAllMediaButton ]
|
||||
)
|
||||
|
||||
if let viewController: UIViewController = viewController {
|
||||
self.present(viewController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
guard let stream = attachment as? TSAttachmentStream else { return }
|
||||
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
|
||||
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
|
||||
}
|
||||
|
||||
case .genericAttachment:
|
||||
guard
|
||||
let attachment: Attachment = item.attachments?.first,
|
||||
|
@ -1554,9 +1553,9 @@ extension ConversationVC {
|
|||
alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in
|
||||
// Delete the request
|
||||
GRDBStorage.shared.writeAsync(
|
||||
updates: { [weak self] db in
|
||||
updates: { db in
|
||||
// Update the contact
|
||||
try? Contact
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
|
@ -1586,3 +1585,98 @@ extension ConversationVC {
|
|||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MediaPresentationContextProvider
|
||||
|
||||
extension ConversationVC: MediaPresentationContextProvider {
|
||||
func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
|
||||
guard case let .gallery(galleryItem) = mediaItem else { return nil }
|
||||
// Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an
|
||||
// unsorted array which means we can't use it to determine the desired 'visibleCell'
|
||||
// we are after, due to this we will need to iterate all of the visible cells to find
|
||||
// the one we want
|
||||
let maybeMessageCell: VisibleMessageCell? = tableView.visibleCells
|
||||
.first { cell -> Bool in
|
||||
((cell as? VisibleMessageCell)?
|
||||
.albumView?
|
||||
.itemViews
|
||||
.contains(where: { mediaView in
|
||||
mediaView.attachment.id == galleryItem.attachment.id
|
||||
}))
|
||||
.defaulting(to: false)
|
||||
}
|
||||
.map { $0 as? VisibleMessageCell }
|
||||
let maybeTargetView: MediaView? = maybeMessageCell?
|
||||
.albumView?
|
||||
.itemViews
|
||||
.first(where: { $0.attachment.id == galleryItem.attachment.id })
|
||||
|
||||
guard
|
||||
let messageCell: VisibleMessageCell = maybeMessageCell,
|
||||
let targetView: MediaView = maybeTargetView,
|
||||
let mediaSuperview: UIView = targetView.superview
|
||||
else { return nil }
|
||||
|
||||
let cornerRadius: CGFloat
|
||||
let cornerMask: CACornerMask
|
||||
let presentationFrame = coordinateSpace.convert(targetView.frame, from: mediaSuperview)
|
||||
|
||||
if messageCell.bubbleView.bounds == targetView.bounds {
|
||||
cornerRadius = messageCell.bubbleView.layer.cornerRadius
|
||||
cornerMask = messageCell.bubbleView.layer.maskedCorners
|
||||
}
|
||||
else {
|
||||
// If the frames don't match then assume it's either multiple images or there is a caption
|
||||
// and determine which corners need to be rounded
|
||||
cornerRadius = messageCell.bubbleView.layer.cornerRadius
|
||||
|
||||
var newCornerMask = CACornerMask()
|
||||
let cellMaskedCorners: CACornerMask = messageCell.bubbleView.layer.maskedCorners
|
||||
|
||||
if
|
||||
cellMaskedCorners.contains(.layerMinXMinYCorner) &&
|
||||
targetView.frame.minX < CGFloat.leastNonzeroMagnitude &&
|
||||
targetView.frame.minY < CGFloat.leastNonzeroMagnitude
|
||||
{
|
||||
newCornerMask.insert(.layerMinXMinYCorner)
|
||||
}
|
||||
|
||||
if
|
||||
cellMaskedCorners.contains(.layerMaxXMinYCorner) &&
|
||||
abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude &&
|
||||
targetView.frame.minY < CGFloat.leastNonzeroMagnitude
|
||||
{
|
||||
newCornerMask.insert(.layerMaxXMinYCorner)
|
||||
}
|
||||
|
||||
if
|
||||
cellMaskedCorners.contains(.layerMinXMaxYCorner) &&
|
||||
targetView.frame.minX < CGFloat.leastNonzeroMagnitude &&
|
||||
abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude
|
||||
{
|
||||
newCornerMask.insert(.layerMinXMaxYCorner)
|
||||
}
|
||||
|
||||
if
|
||||
cellMaskedCorners.contains(.layerMaxXMaxYCorner) &&
|
||||
abs(targetView.frame.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude &&
|
||||
abs(targetView.frame.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude
|
||||
{
|
||||
newCornerMask.insert(.layerMaxXMaxYCorner)
|
||||
}
|
||||
|
||||
cornerMask = newCornerMask
|
||||
}
|
||||
|
||||
return MediaPresentationContext(
|
||||
mediaView: targetView,
|
||||
presentationFrame: presentationFrame,
|
||||
cornerRadius: cornerRadius,
|
||||
cornerMask: cornerMask
|
||||
)
|
||||
}
|
||||
|
||||
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
|
||||
return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(OWSMediaAlbumView)
|
||||
public class MediaAlbumView: UIStackView {
|
||||
private let items: [ConversationMediaAlbumItem]
|
||||
|
||||
@objc
|
||||
private let items: [Attachment]
|
||||
public let itemViews: [MediaView]
|
||||
|
||||
@objc
|
||||
public var moreItemsView: MediaView?
|
||||
|
||||
private static let kSpacingPts: CGFloat = 2
|
||||
|
@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView {
|
|||
notImplemented()
|
||||
}
|
||||
|
||||
@objc
|
||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
||||
items: [ConversationMediaAlbumItem],
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat) {
|
||||
public required init(
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
items: [Attachment],
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
self.items = items
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
|
||||
let result = MediaView(mediaCache: mediaCache,
|
||||
attachment: $0.attachment,
|
||||
isOutgoing: isOutgoing,
|
||||
maxMessageWidth: maxMessageWidth)
|
||||
return result
|
||||
}
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||
.map {
|
||||
MediaView(
|
||||
mediaCache: mediaCache,
|
||||
attachment: $0,
|
||||
isOutgoing: isOutgoing,
|
||||
maxMessageWidth: maxMessageWidth
|
||||
)
|
||||
}
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
|
@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView {
|
|||
|
||||
private func createContents(maxMessageWidth: CGFloat) {
|
||||
switch itemViews.count {
|
||||
case 0:
|
||||
owsFailDebug("No item views.")
|
||||
return
|
||||
case 1:
|
||||
// X
|
||||
guard let itemView = itemViews.first else {
|
||||
owsFailDebug("Missing item view.")
|
||||
return
|
||||
}
|
||||
addSubview(itemView)
|
||||
itemView.autoPinEdgesToSuperviewEdges()
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
autoSet(viewSize: imageSize, ofViews: itemViews)
|
||||
for itemView in itemViews {
|
||||
addArrangedSubview(itemView)
|
||||
}
|
||||
self.axis = .horizontal
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
|
||||
|
||||
guard let leftItemView = itemViews.first else {
|
||||
owsFailDebug("Missing view")
|
||||
return
|
||||
}
|
||||
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
|
||||
addArrangedSubview(leftItemView)
|
||||
|
||||
let rightViews = Array(itemViews[1..<3])
|
||||
addArrangedSubview(newRow(rowViews: rightViews,
|
||||
axis: .vertical,
|
||||
viewSize: smallImageSize))
|
||||
self.axis = .horizontal
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
case 4:
|
||||
// X X
|
||||
// X X
|
||||
// Square
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(newRow(rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize))
|
||||
|
||||
let bottomViews = Array(itemViews[2..<4])
|
||||
addArrangedSubview(newRow(rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize))
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(newRow(rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: bigImageSize))
|
||||
|
||||
let bottomViews = Array(itemViews[2..<5])
|
||||
addArrangedSubview(newRow(rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: smallImageSize))
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
if items.count > MediaAlbumView.kMaxItems {
|
||||
guard let lastView = bottomViews.last else {
|
||||
owsFailDebug("Missing lastView")
|
||||
case 0: return owsFailDebug("No item views.")
|
||||
|
||||
case 1:
|
||||
// X
|
||||
guard let itemView = itemViews.first else {
|
||||
owsFailDebug("Missing item view.")
|
||||
return
|
||||
}
|
||||
addSubview(itemView)
|
||||
itemView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
autoSet(viewSize: imageSize, ofViews: itemViews)
|
||||
for itemView in itemViews {
|
||||
addArrangedSubview(itemView)
|
||||
}
|
||||
self.axis = .horizontal
|
||||
self.distribution = .fillEqually
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
|
||||
|
||||
moreItemsView = lastView
|
||||
guard let leftItemView = itemViews.first else {
|
||||
owsFailDebug("Missing view")
|
||||
return
|
||||
}
|
||||
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
|
||||
addArrangedSubview(leftItemView)
|
||||
|
||||
let tintView = UIView()
|
||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
||||
lastView.addSubview(tintView)
|
||||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
let rightViews = Array(itemViews[1..<3])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: rightViews,
|
||||
axis: .vertical,
|
||||
viewSize: smallImageSize
|
||||
)
|
||||
)
|
||||
self.axis = .horizontal
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
case 4:
|
||||
// X X
|
||||
// X X
|
||||
// Square
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
|
||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
|
||||
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText)
|
||||
let moreLabel = UILabel()
|
||||
moreLabel.text = moreText
|
||||
moreLabel.textColor = UIColor.ows_white
|
||||
// We don't want to use dynamic text here.
|
||||
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
||||
lastView.addSubview(moreLabel)
|
||||
moreLabel.autoCenterInSuperview()
|
||||
}
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize
|
||||
)
|
||||
)
|
||||
|
||||
let bottomViews = Array(itemViews[2..<4])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize
|
||||
)
|
||||
)
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: bigImageSize
|
||||
)
|
||||
)
|
||||
|
||||
let bottomViews = Array(itemViews[2..<5])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: smallImageSize
|
||||
)
|
||||
)
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
if items.count > MediaAlbumView.kMaxItems {
|
||||
guard let lastView = bottomViews.last else {
|
||||
owsFailDebug("Missing lastView")
|
||||
return
|
||||
}
|
||||
|
||||
moreItemsView = lastView
|
||||
|
||||
let tintView = UIView()
|
||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
||||
lastView.addSubview(tintView)
|
||||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(
|
||||
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
||||
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
||||
moreCountText
|
||||
)
|
||||
let moreLabel = UILabel()
|
||||
moreLabel.text = moreText
|
||||
moreLabel.textColor = UIColor.ows_white
|
||||
// We don't want to use dynamic text here.
|
||||
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
||||
lastView.addSubview(moreLabel)
|
||||
moreLabel.autoCenterInSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
for itemView in itemViews {
|
||||
|
@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView {
|
|||
}
|
||||
}
|
||||
|
||||
private func autoSet(viewSize: CGFloat,
|
||||
ofViews views: [MediaView]) {
|
||||
private func autoSet(
|
||||
viewSize: CGFloat,
|
||||
ofViews views: [MediaView]
|
||||
) {
|
||||
for itemView in views {
|
||||
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
||||
}
|
||||
}
|
||||
|
||||
private func newRow(rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis,
|
||||
viewSize: CGFloat) -> UIStackView {
|
||||
private func newRow(
|
||||
rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis,
|
||||
viewSize: CGFloat
|
||||
) -> UIStackView {
|
||||
autoSet(viewSize: viewSize, ofViews: rowViews)
|
||||
return newRow(rowViews: rowViews, axis: axis)
|
||||
}
|
||||
|
||||
private func newRow(rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis) -> UIStackView {
|
||||
private func newRow(
|
||||
rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis
|
||||
) -> UIStackView {
|
||||
let stackView = UIStackView(arrangedSubviews: rowViews)
|
||||
stackView.axis = axis
|
||||
stackView.spacing = MediaAlbumView.kSpacingPts
|
||||
return stackView
|
||||
}
|
||||
|
||||
@objc
|
||||
public func loadMedia() {
|
||||
for itemView in itemViews {
|
||||
itemView.loadMedia()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func unloadMedia() {
|
||||
for itemView in itemViews {
|
||||
itemView.unloadMedia()
|
||||
}
|
||||
}
|
||||
|
||||
private class func itemsToDisplay(forItems items: [ConversationMediaAlbumItem]) -> [ConversationMediaAlbumItem] {
|
||||
private class func itemsToDisplay(forItems items: [Attachment]) -> [Attachment] {
|
||||
// TODO: Unless design changes, we want to display
|
||||
// items which are still downloading and invalid
|
||||
// items.
|
||||
|
@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView {
|
|||
return validItems
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||
items: [ConversationMediaAlbumItem]) -> CGSize {
|
||||
public class func layoutSize(
|
||||
forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||
items: [Attachment]
|
||||
) -> CGSize {
|
||||
let itemCount = itemsToDisplay(forItems: items).count
|
||||
|
||||
switch itemCount {
|
||||
case 0, 1, 4:
|
||||
// X
|
||||
//
|
||||
// or
|
||||
//
|
||||
// XX
|
||||
// XX
|
||||
// Square
|
||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
||||
case 0, 1, 4:
|
||||
// X
|
||||
//
|
||||
// or
|
||||
//
|
||||
// XX
|
||||
// XX
|
||||
// Square
|
||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func mediaView(forLocation location: CGPoint) -> MediaView? {
|
||||
var bestMediaView: MediaView?
|
||||
var bestDistance: CGFloat = 0
|
||||
|
@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView {
|
|||
return bestMediaView
|
||||
}
|
||||
|
||||
@objc
|
||||
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
||||
return moreItemsView == mediaView
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class MediaPlaceholderView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class MediaPlaceholderView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(item: ConversationViewModel.Item, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(item: item, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(
|
||||
item: ConversationViewModel.Item,
|
||||
textColor: UIColor
|
||||
) {
|
||||
let (iconName, attachmentDescription): (String, String) = {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
|
||||
var attachments: [TSAttachment] = []
|
||||
Storage.read { transaction in
|
||||
attachments = message.attachments(with: transaction)
|
||||
guard
|
||||
item.interactionVariant == .standardIncoming,
|
||||
let attachment: Attachment = item.attachments?.first
|
||||
else {
|
||||
return ("actionsheet_document_black", "file") // Should never occur
|
||||
}
|
||||
guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur
|
||||
if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") }
|
||||
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
|
||||
|
||||
if attachment.isAudio { return ("attachment_audio", "audio") }
|
||||
if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
|
||||
|
||||
return ("actionsheet_document_black", "file")
|
||||
}()
|
||||
|
||||
// Image view
|
||||
let iconSize = MediaPlaceholderView.iconSize
|
||||
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView = UIImageView(
|
||||
image: UIImage(named: iconName)?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
.resizedImage(
|
||||
to: CGSize(
|
||||
width: MediaPlaceholderView.iconSize,
|
||||
height: MediaPlaceholderView.iconSize
|
||||
)
|
||||
)
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
|
||||
imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(OWSMediaView)
|
||||
public class MediaView: UIView {
|
||||
|
||||
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
||||
private enum MediaError {
|
||||
case missing
|
||||
case invalid
|
||||
|
@ -17,8 +17,7 @@ public class MediaView: UIView {
|
|||
// MARK: -
|
||||
|
||||
private let mediaCache: NSCache<NSString, AnyObject>
|
||||
@objc
|
||||
public let attachment: TSAttachment
|
||||
public let attachment: Attachment
|
||||
private let isOutgoing: Bool
|
||||
private let maxMessageWidth: CGFloat
|
||||
private var loadBlock: (() -> Void)?
|
||||
|
@ -42,50 +41,16 @@ public class MediaView: UIView {
|
|||
case failed
|
||||
}
|
||||
|
||||
// Thread-safe access to load state.
|
||||
//
|
||||
// We use a "box" class so that we can capture a reference
|
||||
// to this box (rather than self) and a) safely access
|
||||
// if off the main thread b) not prevent deallocation of
|
||||
// self.
|
||||
private class ThreadSafeLoadState {
|
||||
private var value: LoadState
|
||||
|
||||
required init(_ value: LoadState) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func get() -> LoadState {
|
||||
objc_sync_enter(self)
|
||||
let valueCopy = value
|
||||
objc_sync_exit(self)
|
||||
return valueCopy
|
||||
}
|
||||
|
||||
func set(_ newValue: LoadState) {
|
||||
objc_sync_enter(self)
|
||||
value = newValue
|
||||
objc_sync_exit(self)
|
||||
}
|
||||
}
|
||||
private let threadSafeLoadState = ThreadSafeLoadState(.unloaded)
|
||||
// Convenience accessors.
|
||||
private var loadState: LoadState {
|
||||
get {
|
||||
return threadSafeLoadState.get()
|
||||
}
|
||||
set {
|
||||
threadSafeLoadState.set(newValue)
|
||||
}
|
||||
}
|
||||
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
|
||||
|
||||
// MARK: - Initializers
|
||||
|
||||
@objc
|
||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
||||
attachment: TSAttachment,
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat) {
|
||||
public required init(
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
attachment: Attachment,
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
self.mediaCache = mediaCache
|
||||
self.attachment = attachment
|
||||
self.isOutgoing = isOutgoing
|
||||
|
@ -105,9 +70,7 @@ public class MediaView: UIView {
|
|||
}
|
||||
|
||||
deinit {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadState = .unloaded
|
||||
loadState.mutate { $0 = .unloaded }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
@ -115,41 +78,41 @@ public class MediaView: UIView {
|
|||
private func createContents() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
||||
guard attachment.state == .uploaded || attachment.state == .downloaded else {
|
||||
addDownloadProgressIfNecessary()
|
||||
return
|
||||
}
|
||||
guard !isFailedDownload else {
|
||||
guard attachment.state != .failed else {
|
||||
configure(forError: .failed)
|
||||
return
|
||||
}
|
||||
if attachmentStream.isAnimated {
|
||||
configureForAnimatedImage(attachmentStream: attachmentStream)
|
||||
} else if attachmentStream.isImage {
|
||||
configureForStillImage(attachmentStream: attachmentStream)
|
||||
} else if attachmentStream.isVideo {
|
||||
configureForVideo(attachmentStream: attachmentStream)
|
||||
} else {
|
||||
|
||||
if attachment.isAnimated {
|
||||
configureForAnimatedImage(attachment: attachment)
|
||||
}
|
||||
else if attachment.isImage {
|
||||
configureForStillImage(attachment: attachment)
|
||||
}
|
||||
else if attachment.isVideo {
|
||||
configureForVideo(attachment: attachment)
|
||||
}
|
||||
else {
|
||||
owsFailDebug("Attachment has unexpected type.")
|
||||
configure(forError: .invalid)
|
||||
}
|
||||
}
|
||||
|
||||
private func addDownloadProgressIfNecessary() {
|
||||
guard !isFailedDownload else {
|
||||
guard attachment.state != .failed else {
|
||||
configure(forError: .failed)
|
||||
return
|
||||
}
|
||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
||||
owsFailDebug("Attachment has unexpected type.")
|
||||
configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
guard attachmentPointer.pointerType == .incoming else {
|
||||
guard attachment.state != .uploading && attachment.state != .uploaded else {
|
||||
// TODO: Show "restoring" indicator and possibly progress.
|
||||
configure(forError: .missing)
|
||||
return
|
||||
}
|
||||
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
|
@ -158,23 +121,20 @@ public class MediaView: UIView {
|
|||
|
||||
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
||||
guard isOutgoing else { return false }
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else { return false }
|
||||
guard !attachmentStream.isUploaded else { return false }
|
||||
guard attachment.state != .uploaded else { return false }
|
||||
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
let animatedImageView = YYAnimatedImageView()
|
||||
private func configureForAnimatedImage(attachment: Attachment) {
|
||||
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
animatedImageView.contentMode = .scaleAspectFill
|
||||
animatedImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
animatedImageView.layer.minificationFilter = .trilinear
|
||||
|
@ -187,36 +147,37 @@ public class MediaView: UIView {
|
|||
loadBlock = { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
if animatedImageView.image != nil {
|
||||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidImage else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
guard let filePath = attachmentStream.originalFilePath else {
|
||||
owsFailDebug("Attachment stream missing original file path.")
|
||||
return nil
|
||||
}
|
||||
let animatedImage = YYImage(contentsOfFile: filePath)
|
||||
return animatedImage
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image = media as? YYImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
animatedImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
strongSelf.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return
|
||||
}
|
||||
guard let filePath: String = attachment.originalFilePath else {
|
||||
owsFailDebug("Attachment stream missing original file path.")
|
||||
return
|
||||
}
|
||||
|
||||
applyMediaBlock(YYImage(contentsOfFile: filePath))
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image: YYImage = media as? YYImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
|
||||
animatedImageView.image = image
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -225,15 +186,11 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func configureForStillImage(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
private func configureForStillImage(attachment: Attachment) {
|
||||
let stillImageView = UIImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
stillImageView.contentMode = .scaleAspectFill
|
||||
stillImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
stillImageView.layer.minificationFilter = .trilinear
|
||||
|
@ -242,6 +199,7 @@ public class MediaView: UIView {
|
|||
addSubview(stillImageView)
|
||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||
_ = addUploadProgressIfNecessary(stillImageView)
|
||||
|
||||
loadBlock = { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
|
@ -249,29 +207,31 @@ public class MediaView: UIView {
|
|||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidImage else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
return attachmentStream.thumbnailImageLarge(success: { (image) in
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return
|
||||
}
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .large,
|
||||
success: { image, _ in applyMediaBlock(image) },
|
||||
failure: { Logger.error("Could not load thumbnail") }
|
||||
)
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
|
||||
guard let image: UIImage = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
|
||||
stillImageView.image = image
|
||||
}, failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
})
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
stillImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -280,15 +240,11 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func configureForVideo(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
private func configureForVideo(attachment: Attachment) {
|
||||
let stillImageView = UIImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
stillImageView.contentMode = .scaleAspectFill
|
||||
stillImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
stillImageView.layer.minificationFilter = .trilinear
|
||||
|
@ -314,29 +270,31 @@ public class MediaView: UIView {
|
|||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidVideo else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
return attachmentStream.thumbnailImageMedium(success: { (image) in
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return
|
||||
}
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .medium,
|
||||
success: { image, _ in applyMediaBlock(image) },
|
||||
failure: { Logger.error("Could not load thumbnail") }
|
||||
)
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image: UIImage = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
|
||||
stillImageView.image = image
|
||||
}, failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
})
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
stillImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -345,101 +303,89 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private var isFailedDownload: Bool {
|
||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
||||
return false
|
||||
}
|
||||
return attachmentPointer.state == .failed
|
||||
}
|
||||
|
||||
private func configure(forError error: MediaError) {
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let icon: UIImage
|
||||
|
||||
switch error {
|
||||
case .failed:
|
||||
guard let asset = UIImage(named: "media_retry") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
case .invalid:
|
||||
guard let asset = UIImage(named: "media_invalid") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
case .missing:
|
||||
return
|
||||
case .failed:
|
||||
guard let asset = UIImage(named: "media_retry") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
|
||||
case .invalid:
|
||||
guard let asset = UIImage(named: "media_invalid") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
|
||||
case .missing: return
|
||||
}
|
||||
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
|
||||
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
addSubview(iconView)
|
||||
iconView.autoCenterInSuperview()
|
||||
}
|
||||
|
||||
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?,
|
||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||
cacheKey: String) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
private func tryToLoadMedia(
|
||||
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
|
||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||
cacheKey: String
|
||||
) {
|
||||
// It's critical that we update loadState once
|
||||
// our load attempt is complete.
|
||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
guard strongSelf.loadState == .loading else {
|
||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
|
||||
guard self?.loadState.wrappedValue == .loading else {
|
||||
Logger.verbose("Skipping obsolete load.")
|
||||
return
|
||||
}
|
||||
guard let media = possibleMedia else {
|
||||
strongSelf.loadState = .failed
|
||||
guard let media: AnyObject = possibleMedia else {
|
||||
self?.loadState.mutate { $0 = .failed }
|
||||
// TODO:
|
||||
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
applyMediaBlock(media)
|
||||
|
||||
strongSelf.loadState = .loaded
|
||||
|
||||
self?.mediaCache.setObject(media, forKey: cacheKey as NSString)
|
||||
self?.loadState.mutate { $0 = .loaded }
|
||||
}
|
||||
|
||||
guard loadState == .loading else {
|
||||
guard loadState.wrappedValue == .loading else {
|
||||
owsFailDebug("Unexpected load state: \(loadState)")
|
||||
return
|
||||
}
|
||||
|
||||
let mediaCache = self.mediaCache
|
||||
if let media = mediaCache.object(forKey: cacheKey as NSString) {
|
||||
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
|
||||
Logger.verbose("media cache hit")
|
||||
|
||||
guard !Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
loadMediaBlock(loadCompletion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadCompletion(media)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.verbose("media cache miss")
|
||||
|
||||
let threadSafeLoadState = self.threadSafeLoadState
|
||||
MediaView.loadQueue.async {
|
||||
guard threadSafeLoadState.get() == .loading else {
|
||||
MediaView.loadQueue.async { [weak self] in
|
||||
guard self?.loadState.wrappedValue == .loading else {
|
||||
Logger.verbose("Skipping obsolete load.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let media = loadMediaBlock() else {
|
||||
Logger.error("Failed to load media.")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
mediaCache.setObject(media, forKey: cacheKey as NSString)
|
||||
|
||||
loadCompletion(media)
|
||||
loadMediaBlock(loadCompletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -459,32 +405,18 @@ public class MediaView: UIView {
|
|||
// "skip rate" of obsolete loads.
|
||||
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
||||
|
||||
@objc
|
||||
public func loadMedia() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
switch loadState {
|
||||
case .unloaded:
|
||||
loadState = .loading
|
||||
|
||||
guard let loadBlock = loadBlock else {
|
||||
return
|
||||
}
|
||||
loadBlock()
|
||||
case .loading, .loaded, .failed:
|
||||
break
|
||||
switch loadState.wrappedValue {
|
||||
case .unloaded:
|
||||
loadState.mutate { $0 = .loading }
|
||||
loadBlock?()
|
||||
|
||||
case .loading, .loaded, .failed: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func unloadMedia() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadState = .unloaded
|
||||
|
||||
guard let unloadBlock = unloadBlock else {
|
||||
return
|
||||
}
|
||||
unloadBlock()
|
||||
loadState.mutate { $0 = .unloaded }
|
||||
unloadBlock?()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -135,7 +135,7 @@ final class QuoteView: UIView {
|
|||
|
||||
attachment.thumbnail(
|
||||
size: .small,
|
||||
success: { image in
|
||||
success: { image, _ in
|
||||
DispatchQueue.main.async {
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -767,7 +767,7 @@ CGFloat kIconViewLength = 24;
|
|||
- (void)leaveGroup
|
||||
{
|
||||
if (self.isClosedGroup) {
|
||||
[[SNMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
||||
[[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
||||
}
|
||||
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
|
@ -818,7 +818,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// If we successfully blocked then force a config sync
|
||||
if (isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
[SMKMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
|
@ -837,7 +837,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// If we successfully unblocked then force a config sync
|
||||
if (!isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
[SMKMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
|
@ -900,12 +900,8 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
OWSLogDebug(@"");
|
||||
|
||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithSliderEnabledForThreadId:self.threadId isClosedGroup: self.isClosedGroup isOpenGroup: self.isOpenGroup];
|
||||
|
||||
self.mediaGallery = mediaGallery;
|
||||
|
||||
OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]);
|
||||
[mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController];
|
||||
[SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController];
|
||||
}
|
||||
|
||||
- (void)tappedConversationSearch
|
||||
|
|
|
@ -54,7 +54,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
|
||||
collectionView.register(view: PhotoGridViewCell.self)
|
||||
|
||||
// ensure images at the end of the list can be scrolled above the bottom buttons
|
||||
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
|
||||
|
@ -543,11 +543,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return UICollectionViewCell(forAutoLayout: ())
|
||||
}
|
||||
|
||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
||||
owsFail("cell was unexpectedly nil")
|
||||
}
|
||||
|
||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
||||
|
||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
|
|
|
@ -1,3 +1,58 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class MediaGalleryViewModel {
|
||||
public let threadId: String
|
||||
public let threadVariant: SessionThread.Variant
|
||||
private let item: ConversationViewModel.Item?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
item: ConversationViewModel.Item? = nil
|
||||
) {
|
||||
self.threadId = threadId
|
||||
self.threadVariant = threadVariant
|
||||
self.item = item
|
||||
}
|
||||
}
|
||||
|
||||
public static func createTileViewController(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool) -> MediaTileViewController {
|
||||
return MediaTileViewController(
|
||||
viewModel: MediaGalleryViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: {
|
||||
if isClosedGroup { return .closedGroup }
|
||||
if isOpenGroup { return .openGroup }
|
||||
|
||||
return .contact
|
||||
}()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when we can
|
||||
|
||||
@objc(SNMediaGallery)
|
||||
public class SNMediaGallery: NSObject {
|
||||
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
|
||||
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) {
|
||||
fromNavController.pushViewController(
|
||||
MediaGalleryViewModel.createTileViewController(
|
||||
threadId: threadId,
|
||||
isClosedGroup: isClosedGroup,
|
||||
isOpenGroup: isOpenGroup
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUIKit
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public protocol MediaTileViewControllerDelegate: class {
|
||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem)
|
||||
public protocol MediaTileViewControllerDelegate: AnyObject {
|
||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryViewModel.Item)
|
||||
}
|
||||
|
||||
public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout {
|
||||
|
|
|
@ -16,9 +16,6 @@ public protocol PhotoGridItem: AnyObject {
|
|||
}
|
||||
|
||||
public class PhotoGridViewCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "PhotoGridViewCell"
|
||||
|
||||
public let imageView: UIImageView
|
||||
|
||||
private let contentTypeBadgeView: UIImageView
|
||||
|
@ -128,7 +125,9 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
Logger.debug("image == nil")
|
||||
}
|
||||
|
||||
self?.image = image
|
||||
DispatchQueue.main.async {
|
||||
self?.image = image
|
||||
}
|
||||
}
|
||||
|
||||
switch item.type {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 let attachmentID: String
|
||||
internal let threadID: String
|
||||
internal let message: Message
|
||||
internal let message: _Message
|
||||
internal let messageSendJobID: String
|
||||
internal var id: String?
|
||||
internal var failureCount: UInt = 0
|
||||
|
@ -1121,7 +1121,7 @@ public enum Legacy {
|
|||
guard
|
||||
let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
|
||||
let threadID = coder.decodeObject(forKey: "threadID") as! String?,
|
||||
let message = coder.decodeObject(forKey: "message") as! Message?,
|
||||
let message = coder.decodeObject(forKey: "message") as! _Message?,
|
||||
let messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?,
|
||||
let id = coder.decodeObject(forKey: "id") as! String?
|
||||
else { return nil }
|
||||
|
|
|
@ -229,6 +229,9 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.column(.width, .integer)
|
||||
t.column(.height, .integer)
|
||||
t.column(.duration, .double)
|
||||
t.column(.isVisualMedia, .boolean)
|
||||
.notNull()
|
||||
.defaults(to: false)
|
||||
t.column(.isValid, .boolean)
|
||||
.notNull()
|
||||
.defaults(to: false)
|
||||
|
|
|
@ -969,7 +969,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
|
||||
transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in
|
||||
guard let job = object as? Legacy.AttachmentUploadJob else { return }
|
||||
guard let job = object as? Legacy._AttachmentUploadJob else { return }
|
||||
attachmentUploadJobs.insert(job)
|
||||
}
|
||||
|
||||
|
@ -1059,7 +1059,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
|
||||
// MARK: - --messageSend
|
||||
|
||||
var messageSendJobIdMap: [String: Int64] = [:]
|
||||
var messageSendJobLegacyMap: [String: Job] = [:]
|
||||
|
||||
try autoreleasepool {
|
||||
try messageSendJobs.forEach { legacyJob in
|
||||
|
@ -1132,31 +1132,42 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
)?.inserted(db)
|
||||
|
||||
if let oldId: String = legacyJob.id, let newId: Int64 = job?.id {
|
||||
messageSendJobIdMap[oldId] = newId
|
||||
messageSendJobLegacyMap[oldId] = job
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - --attachmentUpload
|
||||
|
||||
|
||||
try autoreleasepool {
|
||||
try attachmentUploadJobs.forEach { legacyJob in
|
||||
guard let sendJobId: Int64 = messageSendJobIdMap[legacyJob.messageSendJobID] else {
|
||||
guard let sendJob: Job = messageSendJobLegacyMap[legacyJob.messageSendJobID], let sendJobId: Int64 = sendJob.id else {
|
||||
SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob")
|
||||
throw GRDBStorageError.migrationFailed
|
||||
}
|
||||
|
||||
_ = try Job(
|
||||
|
||||
let uploadJob: Job? = try Job(
|
||||
failureCount: legacyJob.failureCount,
|
||||
variant: .attachmentUpload,
|
||||
behaviour: .runOnce,
|
||||
nextRunTimestamp: 0,
|
||||
threadId: legacyJob.threadID,
|
||||
interactionId: sendJob.interactionId,
|
||||
details: AttachmentUploadJob.Details(
|
||||
threadId: legacyJob.threadID,
|
||||
attachmentId: legacyJob.attachmentID,
|
||||
messageSendJobId: sendJobId
|
||||
messageSendJobId: sendJobId,
|
||||
attachmentId: legacyJob.attachmentID
|
||||
)
|
||||
)?.inserted(db)
|
||||
|
||||
// Add the dependency to the relevant MessageSendJob
|
||||
guard let uploadJobId: Int64 = uploadJob?.id else {
|
||||
SNLog("[Migration Error] attachmentUpload job was not created")
|
||||
throw GRDBStorageError.migrationFailed
|
||||
}
|
||||
|
||||
try JobDependencies(
|
||||
jobId: sendJobId,
|
||||
dependantId: uploadJobId
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import AVFoundation
|
|||
|
||||
public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "attachment" }
|
||||
public static let interactionAttachments = hasOne(InteractionAttachment.self)
|
||||
internal static let interactionAttachments = hasOne(InteractionAttachment.self)
|
||||
internal static let quoteForeignKey = ForeignKey([Columns.id], to: [Quote.Columns.attachmentId])
|
||||
internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId])
|
||||
public static let interaction = hasOne(
|
||||
|
@ -36,6 +36,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
case width
|
||||
case height
|
||||
case duration
|
||||
case isVisualMedia
|
||||
case isValid
|
||||
case encryptionKey
|
||||
case digest
|
||||
|
@ -109,6 +110,9 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
/// The number of seconds the attachment plays for (this will only be set for video and audio attachment types)
|
||||
public let duration: TimeInterval?
|
||||
|
||||
/// A flag indicating whether the attachment data is visual media
|
||||
public let isVisualMedia: Bool
|
||||
|
||||
/// A flag indicating whether the attachment data downloaded is valid for it's content type
|
||||
public let isValid: Bool
|
||||
|
||||
|
@ -137,6 +141,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
width: UInt? = nil,
|
||||
height: UInt? = nil,
|
||||
duration: TimeInterval? = nil,
|
||||
isVisualMedia: Bool? = nil,
|
||||
isValid: Bool = false,
|
||||
encryptionKey: Data? = nil,
|
||||
digest: Data? = nil,
|
||||
|
@ -155,6 +160,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
self.width = width
|
||||
self.height = height
|
||||
self.duration = duration
|
||||
self.isVisualMedia = (isVisualMedia ?? (
|
||||
MIMETypeUtil.isImage(contentType) ||
|
||||
MIMETypeUtil.isVideo(contentType) ||
|
||||
MIMETypeUtil.isAnimated(contentType)
|
||||
))
|
||||
self.isValid = isValid
|
||||
self.encryptionKey = encryptionKey
|
||||
self.digest = digest
|
||||
|
@ -166,9 +176,11 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
id: String = UUID().uuidString,
|
||||
variant: Variant = .standard,
|
||||
contentType: String,
|
||||
dataSource: DataSource
|
||||
dataSource: DataSource,
|
||||
sourceFilename: String? = nil,
|
||||
caption: String? = nil
|
||||
) {
|
||||
guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: nil) else {
|
||||
guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename) else {
|
||||
return nil
|
||||
}
|
||||
guard dataSource.write(toPath: originalFilePath) else { return nil }
|
||||
|
@ -190,16 +202,21 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
self.contentType = contentType
|
||||
self.byteCount = dataSource.dataLength()
|
||||
self.creationTimestamp = nil
|
||||
self.sourceFilename = nil
|
||||
self.sourceFilename = sourceFilename
|
||||
self.downloadUrl = nil
|
||||
self.localRelativeFilePath = URL(fileURLWithPath: originalFilePath).lastPathComponent
|
||||
self.width = imageSize.map { UInt(floor($0.width)) }
|
||||
self.height = imageSize.map { UInt(floor($0.height)) }
|
||||
self.duration = duration
|
||||
self.isVisualMedia = (
|
||||
MIMETypeUtil.isImage(contentType) ||
|
||||
MIMETypeUtil.isVideo(contentType) ||
|
||||
MIMETypeUtil.isAnimated(contentType)
|
||||
)
|
||||
self.isValid = isValid
|
||||
self.encryptionKey = nil
|
||||
self.digest = nil
|
||||
self.caption = nil
|
||||
self.caption = caption
|
||||
}
|
||||
|
||||
// MARK: - Custom Database Interaction
|
||||
|
@ -309,6 +326,7 @@ public extension Attachment {
|
|||
width: width,
|
||||
height: height,
|
||||
duration: duration,
|
||||
isVisualMedia: isVisualMedia,
|
||||
isValid: isValid,
|
||||
encryptionKey: (encryptionKey ?? self.encryptionKey),
|
||||
digest: (digest ?? self.digest),
|
||||
|
@ -353,6 +371,11 @@ public extension Attachment {
|
|||
self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil)
|
||||
self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil)
|
||||
self.duration = nil // Needs to be downloaded to be set
|
||||
self.isVisualMedia = (
|
||||
MIMETypeUtil.isImage(contentType) ||
|
||||
MIMETypeUtil.isVideo(contentType) ||
|
||||
MIMETypeUtil.isAnimated(contentType)
|
||||
)
|
||||
self.isValid = false // Needs to be downloaded to be set
|
||||
self.encryptionKey = proto.key
|
||||
self.digest = proto.digest
|
||||
|
@ -702,8 +725,6 @@ extension Attachment {
|
|||
public var isText: Bool { MIMETypeUtil.isText(contentType) }
|
||||
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
|
||||
|
||||
public var isVisualMedia: Bool { isImage || isVideo || isAnimated }
|
||||
|
||||
public func readDataFromFile() throws -> Data? {
|
||||
guard let filePath: String = self.originalFilePath else {
|
||||
return nil
|
||||
|
@ -716,7 +737,7 @@ extension Attachment {
|
|||
return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg"
|
||||
}
|
||||
|
||||
private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) {
|
||||
private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) {
|
||||
guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else {
|
||||
failure()
|
||||
return
|
||||
|
@ -730,43 +751,113 @@ extension Attachment {
|
|||
return
|
||||
}
|
||||
|
||||
success(image)
|
||||
success(
|
||||
image,
|
||||
{
|
||||
guard let originalFilePath: String = originalFilePath else { throw AttachmentError.invalidData }
|
||||
|
||||
return try Data(contentsOf: URL(fileURLWithPath: originalFilePath))
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let thumbnailPath = thumbnailPath(for: dimensions)
|
||||
|
||||
if FileManager.default.fileExists(atPath: thumbnailPath) {
|
||||
guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else {
|
||||
guard
|
||||
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: thumbnailPath)),
|
||||
let image: UIImage = UIImage(data: data)
|
||||
else {
|
||||
failure()
|
||||
return
|
||||
}
|
||||
|
||||
success(image)
|
||||
success(image, { data })
|
||||
return
|
||||
}
|
||||
|
||||
OWSThumbnailService.shared.ensureThumbnail(
|
||||
for: self,
|
||||
dimensions: dimensions,
|
||||
success: { loadedThumbnail in success(loadedThumbnail.image) },
|
||||
success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) },
|
||||
failure: { _ in failure() }
|
||||
)
|
||||
}
|
||||
|
||||
public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) {
|
||||
public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) {
|
||||
loadThumbnail(with: size.dimension, success: success, failure: failure)
|
||||
}
|
||||
|
||||
public func cloneAsThumbnail() -> Attachment {
|
||||
fatalError("TODO: Add this back")
|
||||
public func cloneAsThumbnail() -> Attachment? {
|
||||
let cloneId: String = UUID().uuidString
|
||||
let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")"
|
||||
|
||||
guard
|
||||
self.isValid,
|
||||
self.isVisualMedia,
|
||||
let thumbnailPath: String = Attachment.originalFilePath(
|
||||
id: cloneId,
|
||||
mimeType: OWSMimeTypeImageJpeg,
|
||||
sourceFilename: thumbnailName
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
// Try generate the thumbnail
|
||||
var thumbnailData: Data?
|
||||
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
self.thumbnail(
|
||||
size: .small,
|
||||
success: { _, dataSourceBlock in
|
||||
thumbnailData = try? dataSourceBlock()
|
||||
semaphore.signal()
|
||||
},
|
||||
failure: { semaphore.signal() }
|
||||
)
|
||||
|
||||
// Wait up to 0.5 seconds
|
||||
_ = semaphore.wait(timeout: .now() + .milliseconds(500))
|
||||
|
||||
guard let thumbnailData: Data = thumbnailData else { return nil }
|
||||
|
||||
// Write the quoted thumbnail to disk
|
||||
do { try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath)) }
|
||||
catch { return nil }
|
||||
|
||||
// Need to retrieve the size of the thumbnail as it maintains it's aspect ratio
|
||||
let thumbnailSize: CGSize = Attachment
|
||||
.imageSize(
|
||||
contentType: OWSMimeTypeImageJpeg,
|
||||
originalFilePath: thumbnailPath
|
||||
)
|
||||
.defaulting(
|
||||
to: CGSize(
|
||||
width: Int(ThumbnailSize.small.dimension),
|
||||
height: Int(ThumbnailSize.small.dimension)
|
||||
)
|
||||
)
|
||||
|
||||
// Copy the thumbnail to a new attachment
|
||||
return Attachment(
|
||||
id: cloneId,
|
||||
variant: .standard,
|
||||
contentType: OWSMimeTypeImageJpeg,
|
||||
byteCount: UInt(thumbnailData.count),
|
||||
sourceFilename: thumbnailName,
|
||||
localRelativeFilePath: thumbnailPath
|
||||
.substring(from: (Attachment.attachmentsFolder.count + 1)), // Leading forward slash
|
||||
width: UInt(thumbnailSize.width),
|
||||
height: UInt(thumbnailSize.height),
|
||||
isValid: true
|
||||
)
|
||||
}
|
||||
|
||||
public func write(data: Data) throws -> Bool {
|
||||
guard let originalFilePath: String = originalFilePath else { return false }
|
||||
|
||||
try data.write(to: URL(fileURLWithPath: originalFilePath))
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -873,6 +964,10 @@ extension Attachment {
|
|||
.with(
|
||||
serverId: "\(fileId)",
|
||||
state: .uploaded,
|
||||
creationTimestamp: (
|
||||
updatedAttachment?.creationTimestamp ??
|
||||
Date().timeIntervalSince1970
|
||||
),
|
||||
downloadUrl: "\(FileServerAPIV2.server)/files/\(fileId)"
|
||||
)
|
||||
.saved(db)
|
||||
|
|
|
@ -8,7 +8,7 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord
|
|||
public static var databaseTableName: String { "interactionAttachment" }
|
||||
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
|
||||
internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id])
|
||||
internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
|
||||
public static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
|
||||
internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey)
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
|
|
|
@ -154,7 +154,7 @@ public extension LinkPreview {
|
|||
return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution)
|
||||
}
|
||||
|
||||
@discardableResult static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? {
|
||||
static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? {
|
||||
guard let imageData: Data = imageData, !imageData.isEmpty else { return nil }
|
||||
guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil }
|
||||
|
||||
|
|
|
@ -106,6 +106,7 @@ public extension Quote {
|
|||
quote.id != 0,
|
||||
!quote.author.isEmpty
|
||||
else { return nil }
|
||||
|
||||
self.interactionId = interactionId
|
||||
self.timestampMs = Int64(quote.id)
|
||||
self.authorId = quote.author
|
||||
|
@ -128,27 +129,24 @@ public extension Quote {
|
|||
}
|
||||
|
||||
// We only use the first attachment
|
||||
if let attachment = proto.attachments.first {
|
||||
let thumbnailAttachment: Attachment
|
||||
|
||||
// We prefer deriving any thumbnail locally rather than fetching one from the network
|
||||
if let quotedInteraction: Interaction = quotedInteraction {
|
||||
if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) {
|
||||
thumbnailAttachment = attachment.cloneAsThumbnail()
|
||||
if let attachment = quote.attachments.first(where: { $0.thumbnail != nil })?.thumbnail {
|
||||
self.attachmentId = try quotedInteraction
|
||||
.map { quotedInteraction -> Attachment? in
|
||||
// If the quotedInteraction has an attachment then try clone it
|
||||
if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) {
|
||||
return attachment.cloneAsThumbnail()
|
||||
}
|
||||
|
||||
// Otherwise if the quotedInteraction has a link preview, try clone that
|
||||
return try? quotedInteraction.linkPreview
|
||||
.fetchOne(db)?
|
||||
.attachment
|
||||
.fetchOne(db)?
|
||||
.cloneAsThumbnail()
|
||||
}
|
||||
else if let linkPreviewAttachment: Attachment = try? quotedInteraction.linkPreview.fetchOne(db)?.attachment.fetchOne(db) {
|
||||
thumbnailAttachment = linkPreviewAttachment.cloneAsThumbnail()
|
||||
}
|
||||
else {
|
||||
thumbnailAttachment = Attachment(proto: attachment)
|
||||
}
|
||||
}
|
||||
else {
|
||||
thumbnailAttachment = Attachment(proto: attachment)
|
||||
}
|
||||
|
||||
try thumbnailAttachment.save(db)
|
||||
self.attachmentId = thumbnailAttachment.id
|
||||
.defaulting(to: Attachment(proto: attachment))
|
||||
.inserted(db)
|
||||
.id
|
||||
}
|
||||
else {
|
||||
self.attachmentId = nil
|
||||
|
@ -158,6 +156,5 @@ public extension Quote {
|
|||
if self.body == nil && self.attachmentId == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ public enum AttachmentDownloadJob: JobExecutor {
|
|||
state: .downloaded,
|
||||
creationTimestamp: Date().timeIntervalSince1970,
|
||||
localRelativeFilePath: attachment.originalFilePath?
|
||||
.substring(from: Attachment.attachmentsFolder.count)
|
||||
.substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash
|
||||
)
|
||||
.save(db)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ public enum AttachmentUploadJob: JobExecutor {
|
|||
public static var maxFailureCount: Int = 10
|
||||
public static var requiresThreadId: Bool = true
|
||||
public static let requiresInteractionId: Bool = true
|
||||
|
||||
public static func run(
|
||||
_ job: Job,
|
||||
success: @escaping (Job, Bool) -> (),
|
||||
|
@ -51,9 +52,18 @@ public enum AttachmentUploadJob: JobExecutor {
|
|||
|
||||
extension AttachmentUploadJob {
|
||||
public struct Details: Codable {
|
||||
/// This is the id for the messageSend job this attachmentUpload job is associated to, the value isn't used for any of
|
||||
/// the logic but we want to mandate that the attachmentUpload job can only be used alongside a messageSend job
|
||||
///
|
||||
/// **Note:** If we do decide to remove this the `_003_YDBToGRDBMigration` will need to be updated as it
|
||||
/// fails if this connection can't be made
|
||||
public let messageSendJobId: Int64
|
||||
|
||||
/// The id of the `Attachment` to upload
|
||||
public let attachmentId: String
|
||||
|
||||
public init(attachmentId: String) {
|
||||
public init(messageSendJobId: Int64, attachmentId: String) {
|
||||
self.messageSendJobId = messageSendJobId
|
||||
self.attachmentId = attachmentId
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,6 +71,7 @@ public enum MessageSendJob: JobExecutor {
|
|||
threadId: job.threadId,
|
||||
interactionId: interactionId,
|
||||
details: AttachmentUploadJob.Details(
|
||||
messageSendJobId: jobId,
|
||||
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
|
||||
}
|
||||
|
||||
@objc(leaveClosedGroupWithPublicKey:)
|
||||
public static func objc_leave(_ groupPublicKey: String) -> AnyPromise {
|
||||
let promise = GRDBStorage.shared.write { db in
|
||||
try leave(db, groupPublicKey: groupPublicKey)
|
||||
}
|
||||
|
||||
return AnyPromise.from(promise)
|
||||
}
|
||||
|
||||
/// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the
|
||||
/// user is a regular member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave
|
||||
/// message). The admin can then truly remove them later.
|
||||
|
|
|
@ -11,8 +11,8 @@ extension MessageSender {
|
|||
|
||||
public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws {
|
||||
guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved }
|
||||
// TODO: Is the 'prep' method needed anymore?
|
||||
// prep(db, attachments, for: message)
|
||||
|
||||
try prep(db, signalAttachments: attachments, for: interactionId)
|
||||
send(
|
||||
db,
|
||||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
|
@ -175,12 +175,3 @@ extension MessageSender {
|
|||
return promise
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageSender {
|
||||
@objc(forceSyncConfigurationNow)
|
||||
public static func objc_forceSyncConfigurationNow() {
|
||||
GRDBStorage.shared.write { db in
|
||||
try syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,63 +6,36 @@ import PromiseKit
|
|||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc(SNMessageSender)
|
||||
public final class MessageSender : NSObject {
|
||||
// MARK: Initialization
|
||||
private override init() { }
|
||||
|
||||
public final class MessageSender {
|
||||
// MARK: - Preparation
|
||||
|
||||
public static func prep(
|
||||
_ db: Database,
|
||||
signalAttachments: [SignalAttachment],
|
||||
for message: VisibleMessage
|
||||
) {
|
||||
guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else {
|
||||
#if DEBUG
|
||||
preconditionFailure()
|
||||
#else
|
||||
return
|
||||
#endif
|
||||
for interactionId: Int64
|
||||
) throws {
|
||||
try signalAttachments.forEach { signalAttachment in
|
||||
let maybeAttachment: Attachment? = Attachment(
|
||||
variant: (signalAttachment.isVoiceMessage ?
|
||||
.voiceMessage :
|
||||
.standard
|
||||
),
|
||||
contentType: signalAttachment.mimeType,
|
||||
dataSource: signalAttachment.dataSource,
|
||||
sourceFilename: signalAttachment.sourceFilename,
|
||||
caption: signalAttachment.captionText
|
||||
)
|
||||
|
||||
guard let attachment: Attachment = maybeAttachment else { return }
|
||||
|
||||
let interactionAttachment: InteractionAttachment = InteractionAttachment(
|
||||
interactionId: interactionId,
|
||||
attachmentId: attachment.id
|
||||
)
|
||||
|
||||
try attachment.insert(db)
|
||||
try interactionAttachment.insert(db)
|
||||
}
|
||||
var attachments: [TSAttachmentStream] = []
|
||||
signalAttachments.forEach {
|
||||
let attachment = TSAttachmentStream(contentType: $0.mimeType, byteCount: UInt32($0.dataLength), sourceFilename: $0.sourceFilename,
|
||||
caption: $0.captionText, albumMessageId: tsMessage.uniqueId!)
|
||||
attachment.attachmentType = $0.isVoiceMessage ? .voiceMessage : .default
|
||||
attachments.append(attachment)
|
||||
attachment.write($0.dataSource)
|
||||
attachment.save(with: transaction)
|
||||
}
|
||||
prep(attachments, for: message, using: transaction)
|
||||
}
|
||||
|
||||
@objc(prep:forMessage:usingTransaction:)
|
||||
public static func prep(_ attachmentStreams: [TSAttachmentStream], for message: VisibleMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else {
|
||||
#if DEBUG
|
||||
preconditionFailure()
|
||||
#else
|
||||
return
|
||||
#endif
|
||||
}
|
||||
var attachments = attachmentStreams
|
||||
// The line below locally generates a thumbnail for the quoted attachment. It just needs to happen at some point during the
|
||||
// message sending process.
|
||||
tsMessage.quotedMessage?.createThumbnailAttachmentsIfNecessary(with: transaction)
|
||||
var linkPreviewAttachmentID: String?
|
||||
if let id = tsMessage.linkPreview?.imageAttachmentId,
|
||||
let attachment = TSAttachment.fetch(uniqueId: id, transaction: transaction) as? TSAttachmentStream {
|
||||
linkPreviewAttachmentID = id
|
||||
attachments.append(attachment)
|
||||
}
|
||||
// Anything added to message.attachmentIDs will be uploaded by an UploadAttachmentJob. Any attachment IDs added to tsMessage will
|
||||
// make it render as an attachment (not what we want in the case of a link preview or quoted attachment).
|
||||
message.attachmentIDs = attachments.map { $0.uniqueId! }
|
||||
tsMessage.attachmentIds.removeAllObjects()
|
||||
tsMessage.attachmentIds.addObjects(from: message.attachmentIDs)
|
||||
if let id = linkPreviewAttachmentID { tsMessage.attachmentIds.remove(id) }
|
||||
tsMessage.save(with: transaction)
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
@ -559,3 +532,26 @@ public final class MessageSender : NSObject {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when possible
|
||||
|
||||
@objc(SMKMessageSender)
|
||||
public class SMKMessageSender: NSObject {
|
||||
@objc(leaveClosedGroupWithPublicKey:)
|
||||
public static func objc_leave(_ groupPublicKey: String) -> AnyPromise {
|
||||
let promise = GRDBStorage.shared.write { db in
|
||||
try MessageSender.leave(db, groupPublicKey: groupPublicKey)
|
||||
}
|
||||
|
||||
return AnyPromise.from(promise)
|
||||
}
|
||||
|
||||
@objc(forceSyncConfigurationNow)
|
||||
public static func objc_forceSyncConfigurationNow() {
|
||||
GRDBStorage.shared.write { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 static var databaseTableName: String { "job" }
|
||||
internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId])
|
||||
internal static let dependantJobForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.jobId])
|
||||
internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey)
|
||||
internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey)
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
|
@ -153,6 +155,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
|
|||
request(for: Job.dependencies)
|
||||
}
|
||||
|
||||
/// The other jobs which depend on this job
|
||||
///
|
||||
/// **Note:** When completing a job the dependencies **MUST** be cleared before the job is
|
||||
/// deleted or it will automatically delete any dependant jobs
|
||||
public var dependantJobs: QueryInterfaceRequest<Job> {
|
||||
request(for: Job.dependantJobs)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
fileprivate init(
|
||||
|
@ -225,7 +235,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
|
|||
|
||||
public func delete(_ db: Database) throws -> Bool {
|
||||
// Delete any dependencies
|
||||
try dependencies
|
||||
try dependantJobs
|
||||
.deleteAll(db)
|
||||
|
||||
return try performDelete(db)
|
||||
|
|
|
@ -12,5 +12,6 @@ public extension ReusableView where Self: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
extension UICollectionReusableView: ReusableView {}
|
||||
extension UITableViewCell: 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
|
||||
// Get the max failure count for the job (a value of '-1' means it will retry indefinitely)
|
||||
let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0)
|
||||
let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + getRetryInterval(for: job))
|
||||
|
||||
guard
|
||||
!permanentFailure &&
|
||||
|
@ -537,12 +538,36 @@ public final class JobRunner {
|
|||
}
|
||||
|
||||
SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))")
|
||||
|
||||
_ = try job
|
||||
.with(
|
||||
failureCount: (job.failureCount + 1),
|
||||
nextRunTimestamp: (Date().timeIntervalSince1970 + getRetryInterval(for: job))
|
||||
nextRunTimestamp: nextRunTimestamp
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Update the failureCount and nextRunTimestamp on dependant jobs as well (update the
|
||||
// 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated it'll
|
||||
// come after the dependency)
|
||||
try job.dependantJobs
|
||||
.updateAll(
|
||||
db,
|
||||
Job.Columns.failureCount.set(to: job.failureCount),
|
||||
Job.Columns.nextRunTimestamp.set(to: (nextRunTimestamp + (1 / 1000)))
|
||||
)
|
||||
|
||||
let dependantJobIds: [Int64] = try job.dependantJobs
|
||||
.select(.id)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchAll(db)
|
||||
|
||||
// Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying
|
||||
// to run dependecies indefinitely
|
||||
if !dependantJobIds.isEmpty {
|
||||
jobQueue.mutate { queue in
|
||||
queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
public protocol GalleryRailItemProvider: AnyObject {
|
||||
public protocol GalleryRailItemProvider {
|
||||
var railItems: [GalleryRailItem] { get }
|
||||
}
|
||||
|
||||
public protocol GalleryRailItem: AnyObject {
|
||||
public protocol GalleryRailItem {
|
||||
func buildRailItemView() -> UIView
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue