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:
Morgan Pretty 2022-05-13 18:07:24 +10:00
parent 3f062c044c
commit 8f120c4380
37 changed files with 2091 additions and 1022 deletions

View File

@ -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 */,

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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?()
}
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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)?
}

View File

@ -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)
}
)
}
)
}
}

View File

@ -0,0 +1,44 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
extension UINavigationBar {
func generateSnapshot(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
let scale = UIScreen.main.scale
guard let navBarSuperview: UIView = superview else { return nil }
UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, scale)
guard let context: CGContext = UIGraphicsGetCurrentContext() else {
UIGraphicsEndImageContext()
return nil
}
layer.render(in: context)
guard let image: UIImage = UIGraphicsGetImageFromCurrentImageContext() else {
UIGraphicsEndImageContext()
return nil
}
UIGraphicsEndImageContext()
let snapshotView: UIView = UIView(
frame: CGRect(
x: 0,
y: 0,
width: bounds.width,
height: frame.maxY
)
)
snapshotView.backgroundColor = backgroundColor
let imageView: UIImageView = UIImageView(image: image)
imageView.frame = frame
snapshotView.addSubview(imageView)
let presentationFrame = coordinateSpace.convert(snapshotView.frame, from: navBarSuperview)
return (snapshotView, presentationFrame)
}
}

View File

@ -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 }

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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

View File

@ -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 }

View File

@ -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
}
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -71,6 +71,7 @@ public enum MessageSendJob: JobExecutor {
threadId: job.threadId,
interactionId: interactionId,
details: AttachmentUploadJob.Details(
messageSendJobId: jobId,
attachmentId: stateInfo.attachmentId
)
),

View File

@ -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.")
}
}

View File

@ -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.

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -12,5 +12,6 @@ public extension ReusableView where Self: UIView {
}
}
extension UICollectionReusableView: ReusableView {}
extension UITableViewCell: ReusableView {}
extension UITableViewHeaderFooterView: ReusableView {}

View File

@ -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
}
}

View File

@ -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) }

View File

@ -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
}