Merge pull request #365 from oxen-io/deferred-attachment-downloads
Don't Auto-Download Attachments from Untrusted Contacts
This commit is contained in:
commit
51576acec1
|
@ -294,6 +294,8 @@
|
|||
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; };
|
||||
B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; };
|
||||
B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */; };
|
||||
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; };
|
||||
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */; };
|
||||
B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; };
|
||||
B8FF8DAF25C0D00F004D1F22 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; };
|
||||
B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */; };
|
||||
|
@ -1303,6 +1305,8 @@
|
|||
B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = "<group>"; };
|
||||
B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = "<group>"; };
|
||||
B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotificationInfoMessage.swift; sourceTree = "<group>"; };
|
||||
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = "<group>"; };
|
||||
B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAttachmentModal.swift; sourceTree = "<group>"; };
|
||||
B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = "<group>"; };
|
||||
B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Locations-English"; path = "Countries/GeoLite2-Country-Locations-English"; sourceTree = "<group>"; };
|
||||
B8FF8EA525C11FEF004D1F22 /* IPv4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = "<group>"; };
|
||||
|
@ -2186,6 +2190,7 @@
|
|||
B8569AD225CBA13D00DBA3DB /* MediaTextOverlayView.swift */,
|
||||
3488F9352191CC4000E524CC /* MediaView.swift */,
|
||||
B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */,
|
||||
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */,
|
||||
C328251E25CA3A900062D0A7 /* QuoteView.swift */,
|
||||
34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */,
|
||||
C328250E25CA06020062D0A7 /* VoiceMessageView.swift */,
|
||||
|
@ -2213,6 +2218,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */,
|
||||
B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */,
|
||||
C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */,
|
||||
B821494525D4D6FF009C0F2A /* URLModal.swift */,
|
||||
B821494E25D4E163009C0F2A /* BodyTextView.swift */,
|
||||
|
@ -4996,6 +5002,7 @@
|
|||
3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */,
|
||||
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
|
||||
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
|
||||
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
|
||||
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
|
||||
C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */,
|
||||
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
|
||||
|
@ -5013,6 +5020,7 @@
|
|||
C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */,
|
||||
4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */,
|
||||
34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */,
|
||||
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */,
|
||||
341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */,
|
||||
4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */,
|
||||
C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */,
|
||||
|
@ -5233,7 +5241,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 211;
|
||||
CURRENT_PROJECT_VERSION = 214;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -5254,7 +5262,7 @@
|
|||
INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.9.4;
|
||||
MARKETING_VERSION = 1.9.5;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -5302,7 +5310,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 211;
|
||||
CURRENT_PROJECT_VERSION = 214;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -5328,7 +5336,7 @@
|
|||
INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.9.4;
|
||||
MARKETING_VERSION = 1.9.5;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -5363,7 +5371,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 211;
|
||||
CURRENT_PROJECT_VERSION = 214;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -5382,7 +5390,7 @@
|
|||
INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.9.4;
|
||||
MARKETING_VERSION = 1.9.5;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -5433,7 +5441,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 211;
|
||||
CURRENT_PROJECT_VERSION = 214;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -5457,7 +5465,7 @@
|
|||
INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.9.4;
|
||||
MARKETING_VERSION = 1.9.5;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -6318,7 +6326,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 211;
|
||||
CURRENT_PROJECT_VERSION = 214;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -6354,7 +6362,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 1.9.4;
|
||||
MARKETING_VERSION = 1.9.5;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
|
@ -6386,7 +6394,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 211;
|
||||
CURRENT_PROJECT_VERSION = 214;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -6422,7 +6430,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 1.9.4;
|
||||
MARKETING_VERSION = 1.9.5;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
PRODUCT_NAME = Session;
|
||||
|
|
|
@ -376,43 +376,69 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
|
|||
}
|
||||
|
||||
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) {
|
||||
func confirmDownload() {
|
||||
let modal = DownloadAttachmentModal(viewItem: viewItem)
|
||||
modal.modalPresentationStyle = .overFullScreen
|
||||
modal.modalTransitionStyle = .crossDissolve
|
||||
present(modal, animated: true, completion: nil)
|
||||
}
|
||||
if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
|
||||
// Show the failed message sheet
|
||||
showFailedMessageSheet(for: message)
|
||||
} else {
|
||||
switch viewItem.messageCellType {
|
||||
case .audio: playOrPauseAudio(for: viewItem)
|
||||
case .audio:
|
||||
if viewItem.interaction is TSIncomingMessage,
|
||||
let thread = self.thread as? TSContactThread,
|
||||
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
|
||||
confirmDownload()
|
||||
} else {
|
||||
playOrPauseAudio(for: viewItem)
|
||||
}
|
||||
case .mediaMessage:
|
||||
guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
|
||||
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView = cell.albumView else { return }
|
||||
let locationInCell = gestureRecognizer.location(in: cell)
|
||||
// Figure out whether the "read more" button was tapped
|
||||
if let overlayView = cell.mediaTextOverlayView {
|
||||
let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
|
||||
if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
|
||||
return showFullText(viewItem) // HACK: This is a dirty way to do this
|
||||
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,
|
||||
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
|
||||
confirmDownload()
|
||||
} else {
|
||||
guard let albumView = cell.albumView else { return }
|
||||
let locationInCell = gestureRecognizer.location(in: cell)
|
||||
// Figure out whether the "read more" button was tapped
|
||||
if let overlayView = cell.mediaTextOverlayView {
|
||||
let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
|
||||
if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
|
||||
return showFullText(viewItem) // HACK: This is a dirty way to do this
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, 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() {
|
||||
// TODO: Tapped a failed incoming attachment
|
||||
}
|
||||
let attachment = mediaView.attachment
|
||||
if let pointer = attachment as? TSAttachmentPointer {
|
||||
if pointer.state == .failed {
|
||||
// Otherwise, 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() {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
guard let stream = attachment as? TSAttachmentStream else { return }
|
||||
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
|
||||
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
|
||||
}
|
||||
guard let stream = attachment as? TSAttachmentStream else { return }
|
||||
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
|
||||
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
|
||||
case .genericAttachment:
|
||||
// Open the document if possible
|
||||
guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
|
||||
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
|
||||
navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
if viewItem.interaction is TSIncomingMessage,
|
||||
let thread = self.thread as? TSContactThread,
|
||||
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
|
||||
confirmDownload()
|
||||
} else {
|
||||
// Open the document if possible
|
||||
guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
|
||||
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
|
||||
navigationController!.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
case .textOnlyMessage:
|
||||
if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) {
|
||||
// Open the link preview URL
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
|
||||
final class MediaPlaceholderView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
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
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
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 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") }
|
||||
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)
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: 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
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
}
|
||||
}
|
|
@ -302,6 +302,11 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
|
||||
private func populateContentView(for viewItem: ConversationViewItem) {
|
||||
snContentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
func showMediaPlaceholder() {
|
||||
let mediaPlaceholderView = MediaPlaceholderView(viewItem: viewItem, textColor: bodyLabelTextColor)
|
||||
snContentView.addSubview(mediaPlaceholderView)
|
||||
mediaPlaceholderView.pin(to: snContentView)
|
||||
}
|
||||
albumView = nil
|
||||
bodyTextView = nil
|
||||
mediaTextOverlayView = nil
|
||||
|
@ -337,34 +342,52 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
|
|||
stackView.pin(to: snContentView, withInset: inset)
|
||||
}
|
||||
case .mediaMessage:
|
||||
guard let cache = delegate?.getMediaCache() else { preconditionFailure() }
|
||||
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
|
||||
let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth)
|
||||
self.albumView = albumView
|
||||
snContentView.addSubview(albumView)
|
||||
let size = getSize(for: viewItem)
|
||||
albumView.set(.width, to: size.width)
|
||||
albumView.set(.height, to: size.height)
|
||||
albumView.pin(to: snContentView)
|
||||
albumView.loadMedia()
|
||||
albumView.layer.mask = bubbleViewMaskLayer
|
||||
if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0,
|
||||
let delegate = delegate { // delegate should always be set at this point
|
||||
let overlayView = MediaTextOverlayView(viewItem: viewItem, albumViewWidth: size.width, delegate: delegate)
|
||||
self.mediaTextOverlayView = overlayView
|
||||
snContentView.addSubview(overlayView)
|
||||
overlayView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: snContentView)
|
||||
if viewItem.interaction is TSIncomingMessage,
|
||||
let thread = viewItem.interaction.thread as? TSContactThread,
|
||||
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
|
||||
showMediaPlaceholder()
|
||||
} else {
|
||||
guard let cache = delegate?.getMediaCache() else { preconditionFailure() }
|
||||
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
|
||||
let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth)
|
||||
self.albumView = albumView
|
||||
snContentView.addSubview(albumView)
|
||||
let size = getSize(for: viewItem)
|
||||
albumView.set(.width, to: size.width)
|
||||
albumView.set(.height, to: size.height)
|
||||
albumView.pin(to: snContentView)
|
||||
albumView.loadMedia()
|
||||
albumView.layer.mask = bubbleViewMaskLayer
|
||||
if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0,
|
||||
let delegate = delegate { // delegate should always be set at this point
|
||||
let overlayView = MediaTextOverlayView(viewItem: viewItem, albumViewWidth: size.width, delegate: delegate)
|
||||
self.mediaTextOverlayView = overlayView
|
||||
snContentView.addSubview(overlayView)
|
||||
overlayView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: snContentView)
|
||||
}
|
||||
unloadContent = { albumView.unloadMedia() }
|
||||
}
|
||||
unloadContent = { albumView.unloadMedia() }
|
||||
case .audio:
|
||||
let voiceMessageView = VoiceMessageView(viewItem: viewItem)
|
||||
snContentView.addSubview(voiceMessageView)
|
||||
voiceMessageView.pin(to: snContentView)
|
||||
viewItem.lastAudioMessageView = voiceMessageView
|
||||
if viewItem.interaction is TSIncomingMessage,
|
||||
let thread = viewItem.interaction.thread as? TSContactThread,
|
||||
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
|
||||
showMediaPlaceholder()
|
||||
} else {
|
||||
let voiceMessageView = VoiceMessageView(viewItem: viewItem)
|
||||
snContentView.addSubview(voiceMessageView)
|
||||
voiceMessageView.pin(to: snContentView)
|
||||
viewItem.lastAudioMessageView = voiceMessageView
|
||||
}
|
||||
case .genericAttachment:
|
||||
let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor)
|
||||
snContentView.addSubview(documentView)
|
||||
documentView.pin(to: snContentView)
|
||||
if viewItem.interaction is TSIncomingMessage,
|
||||
let thread = viewItem.interaction.thread as? TSContactThread,
|
||||
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
|
||||
showMediaPlaceholder()
|
||||
} else {
|
||||
let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor)
|
||||
snContentView.addSubview(documentView)
|
||||
documentView.pin(to: snContentView)
|
||||
}
|
||||
default: return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
|
||||
final class DownloadAttachmentModal : Modal {
|
||||
private let viewItem: ConversationViewItem
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem) {
|
||||
self.viewItem = viewItem
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(viewItem:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:) instead.")
|
||||
}
|
||||
|
||||
override func populateContentView() {
|
||||
guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return }
|
||||
// Name
|
||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = "Trust \(name)?"
|
||||
titleLabel.textAlignment = .center
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = "Are you sure you want to download media sent by \(name)?"
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
// Download button
|
||||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
downloadButton.backgroundColor = Colors.buttonBackground
|
||||
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
downloadButton.setTitle("Download", for: UIControl.State.normal)
|
||||
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func trust() {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
|
||||
let publicKey = message.authorId
|
||||
let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey)
|
||||
contact.isTrusted = true
|
||||
Storage.write(with: { transaction in
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
message.touch(with: transaction)
|
||||
}, completion: {
|
||||
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId)
|
||||
})
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ public class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is
|
|||
@objc public var profilePictureEncryptionKey: OWSAES256Key?
|
||||
/// The ID of the thread associated with this contact.
|
||||
@objc public var threadID: String?
|
||||
/// This flag is used to determine whether we should auto-download files sent by this contact.
|
||||
@objc public var isTrusted = false
|
||||
|
||||
// MARK: Name
|
||||
/// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message).
|
||||
|
@ -56,6 +58,7 @@ public class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is
|
|||
public required init?(coder: NSCoder) {
|
||||
guard let sessionID = coder.decodeObject(forKey: "sessionID") as! String? else { return nil }
|
||||
self.sessionID = sessionID
|
||||
isTrusted = coder.decodeBool(forKey: "isTrusted")
|
||||
if let name = coder.decodeObject(forKey: "displayName") as! String? { self.name = name }
|
||||
if let nickname = coder.decodeObject(forKey: "nickname") as! String? { self.nickname = nickname }
|
||||
if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL }
|
||||
|
@ -72,6 +75,7 @@ public class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is
|
|||
coder.encode(profilePictureFileName, forKey: "profilePictureFileName")
|
||||
coder.encode(profilePictureEncryptionKey, forKey: "profilePictureEncryptionKey")
|
||||
coder.encode(threadID, forKey: "threadID")
|
||||
coder.encode(isTrusted, forKey: "isTrusted")
|
||||
}
|
||||
|
||||
// MARK: Equality
|
||||
|
|
|
@ -9,12 +9,18 @@ extension Storage {
|
|||
Storage.read { transaction in
|
||||
result = transaction.object(forKey: sessionID, inCollection: Storage.contactCollection) as? Contact
|
||||
}
|
||||
if let result = result, result.sessionID == getUserHexEncodedPublicKey() {
|
||||
result.isTrusted = true // Always trust ourselves
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@objc(setContact:usingTransaction:)
|
||||
public func setContact(_ contact: Contact, using transaction: Any) {
|
||||
let transaction = transaction as! YapDatabaseReadWriteTransaction
|
||||
if contact.sessionID == getUserHexEncodedPublicKey() {
|
||||
contact.isTrusted = true // Always trust ourselves
|
||||
}
|
||||
transaction.setObject(contact, forKey: contact.sessionID, inCollection: Storage.contactCollection)
|
||||
transaction.addCompletionQueue(DispatchQueue.main) {
|
||||
let notificationCenter = NotificationCenter.default
|
||||
|
|
|
@ -72,6 +72,26 @@ extension Storage {
|
|||
#endif
|
||||
return result.first
|
||||
}
|
||||
|
||||
public func getAttachmentDownloadJobs(for threadID: String) -> [AttachmentDownloadJob] {
|
||||
var result: [AttachmentDownloadJob] = []
|
||||
Storage.read { transaction in
|
||||
transaction.enumerateRows(inCollection: AttachmentDownloadJob.collection) { _, object, _, _ in
|
||||
guard let job = object as? AttachmentDownloadJob, job.threadID == threadID else { return }
|
||||
result.append(job)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func resumeAttachmentDownloadJobsIfNeeded(for threadID: String) {
|
||||
let jobs = getAttachmentDownloadJobs(for: threadID)
|
||||
jobs.forEach { job in
|
||||
job.delegate = JobQueue.shared
|
||||
job.isDeferred = false
|
||||
job.execute()
|
||||
}
|
||||
}
|
||||
|
||||
public func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? {
|
||||
var result: MessageSendJob?
|
||||
|
|
|
@ -5,9 +5,11 @@ import SignalCoreKit
|
|||
public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
|
||||
public let attachmentID: String
|
||||
public let tsMessageID: String
|
||||
public let threadID: String
|
||||
public var delegate: JobDelegate?
|
||||
public var id: String?
|
||||
public var failureCount: UInt = 0
|
||||
public var isDeferred = false
|
||||
|
||||
public enum Error : LocalizedError {
|
||||
case noAttachment
|
||||
|
@ -26,31 +28,38 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject
|
|||
public static let maxFailureCount: UInt = 20
|
||||
|
||||
// MARK: Initialization
|
||||
public init(attachmentID: String, tsMessageID: String) {
|
||||
public init(attachmentID: String, tsMessageID: String, threadID: String) {
|
||||
self.attachmentID = attachmentID
|
||||
self.tsMessageID = tsMessageID
|
||||
self.threadID = threadID
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
public init?(coder: NSCoder) {
|
||||
guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
|
||||
let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?,
|
||||
let threadID = coder.decodeObject(forKey: "threadID") as! String?,
|
||||
let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
|
||||
self.attachmentID = attachmentID
|
||||
self.tsMessageID = tsMessageID
|
||||
self.threadID = threadID
|
||||
self.id = id
|
||||
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
|
||||
self.isDeferred = coder.decodeBool(forKey: "isDeferred")
|
||||
}
|
||||
|
||||
public func encode(with coder: NSCoder) {
|
||||
coder.encode(attachmentID, forKey: "attachmentID")
|
||||
coder.encode(tsMessageID, forKey: "tsIncomingMessageID")
|
||||
coder.encode(threadID, forKey: "threadID")
|
||||
coder.encode(id, forKey: "id")
|
||||
coder.encode(failureCount, forKey: "failureCount")
|
||||
coder.encode(isDeferred, forKey: "isDeferred")
|
||||
}
|
||||
|
||||
// MARK: Running
|
||||
public func execute() {
|
||||
guard !isDeferred else { return }
|
||||
if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream {
|
||||
// FIXME: It's not clear * how * this happens, but apparently we can get to this point
|
||||
// from time to time with an already downloaded attachment.
|
||||
|
@ -99,9 +108,11 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject
|
|||
return handleFailure(error)
|
||||
}
|
||||
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
|
||||
storage.write { transaction in
|
||||
storage.write(with: { transaction in
|
||||
storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
|
||||
}
|
||||
}, completion: {
|
||||
self.handleSuccess()
|
||||
})
|
||||
}.catch(on: DispatchQueue.global()) { error in
|
||||
handleFailure(error)
|
||||
}
|
||||
|
@ -129,9 +140,11 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject
|
|||
return handleFailure(error)
|
||||
}
|
||||
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
|
||||
storage.write { transaction in
|
||||
storage.write(with: { transaction in
|
||||
storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
|
||||
}
|
||||
}, completion: {
|
||||
self.handleSuccess()
|
||||
})
|
||||
}.catch(on: DispatchQueue.global()) { error in
|
||||
handleFailure(error)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import SessionUtilitiesKit
|
|||
@objc(SNJobQueue)
|
||||
public final class JobQueue : NSObject, JobDelegate {
|
||||
|
||||
private static var jobIDs: [UInt64:UInt64] = [:]
|
||||
|
||||
@objc public static let shared = JobQueue()
|
||||
|
||||
@objc public func add(_ job: Job, using transaction: Any) {
|
||||
|
@ -14,7 +16,15 @@ public final class JobQueue : NSObject, JobDelegate {
|
|||
}
|
||||
|
||||
@objc public func addWithoutExecuting(_ job: Job, using transaction: Any) {
|
||||
job.id = String(NSDate.millisecondTimestamp())
|
||||
let timestamp = NSDate.millisecondTimestamp()
|
||||
let count = JobQueue.jobIDs[timestamp] ?? 0
|
||||
// When adding multiple jobs in rapid succession, timestamps might not be good enough as a unique ID. To
|
||||
// deal with this we keep track of the number of jobs with a given timestamp and that to the end of the
|
||||
// timestamp to make it a unique ID. We can't use a random number because we do still want to keep track
|
||||
// of the order in which the jobs were added.
|
||||
let id = String(timestamp) + String(count)
|
||||
job.id = id
|
||||
JobQueue.jobIDs[timestamp] = count + 1
|
||||
SNMessagingKitConfiguration.shared.storage.persist(job, using: transaction)
|
||||
job.delegate = self
|
||||
}
|
||||
|
|
|
@ -278,8 +278,11 @@ extension MessageReceiver {
|
|||
groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread }
|
||||
message.threadID = threadID
|
||||
// Start attachment downloads if needed
|
||||
let isContactTrusted = Storage.shared.getContact(with: message.sender!)?.isTrusted ?? false
|
||||
let isGroup = message.groupPublicKey != nil || openGroupID != nil
|
||||
attachmentsToDownload.forEach { attachmentID in
|
||||
let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID)
|
||||
let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID, threadID: threadID)
|
||||
downloadJob.isDeferred = !isContactTrusted && !isGroup
|
||||
if isMainAppAndActive {
|
||||
JobQueue.shared.add(downloadJob, using: transaction)
|
||||
} else {
|
||||
|
|
|
@ -4,7 +4,7 @@ public class ContactsMigration : OWSDatabaseMigration {
|
|||
|
||||
@objc
|
||||
class func migrationId() -> String {
|
||||
return "112"
|
||||
return "001"
|
||||
}
|
||||
|
||||
override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) {
|
||||
|
@ -12,56 +12,16 @@ public class ContactsMigration : OWSDatabaseMigration {
|
|||
}
|
||||
|
||||
private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) {
|
||||
var contacts: Set<Contact> = []
|
||||
var contacts: [Contact] = []
|
||||
TSContactThread.enumerateCollectionObjects { object, _ in
|
||||
guard let thread = object as? TSContactThread else { return }
|
||||
let sessionID = thread.contactIdentifier()
|
||||
if let contact = Storage.shared.getContact(with: sessionID) {
|
||||
contact.isTrusted = true
|
||||
contacts.append(contact)
|
||||
}
|
||||
}
|
||||
Storage.write(with: { transaction in
|
||||
// Current user
|
||||
if let profile = OWSUserProfile.fetch(uniqueId: kLocalProfileUniqueId, transaction: transaction),
|
||||
let sessionID = TSAccountManager.sharedInstance().localNumber() { // Should always exist
|
||||
let contact = Contact(sessionID: sessionID)
|
||||
contact.name = profile.profileName
|
||||
contact.profilePictureURL = profile.avatarUrlPath
|
||||
contact.profilePictureFileName = profile.avatarFileName
|
||||
contact.profilePictureEncryptionKey = profile.profileKey
|
||||
contacts.insert(contact)
|
||||
}
|
||||
// One-on-one chats
|
||||
TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in
|
||||
guard let thread = object as? TSContactThread else { return }
|
||||
let sessionID = thread.contactIdentifier()
|
||||
let contact = Contact(sessionID: sessionID)
|
||||
var profileOrNil: OWSUserProfile? = nil
|
||||
if sessionID == getUserHexEncodedPublicKey() {
|
||||
profileOrNil = OWSProfileManager.shared().getLocalUserProfile(with: transaction)
|
||||
} else if let profile = OWSUserProfile.fetch(uniqueId: sessionID, transaction: transaction) {
|
||||
profileOrNil = profile
|
||||
}
|
||||
if let profile = profileOrNil {
|
||||
contact.name = profile.profileName
|
||||
contact.profilePictureURL = profile.avatarUrlPath
|
||||
contact.profilePictureFileName = profile.avatarFileName
|
||||
contact.profilePictureEncryptionKey = profile.profileKey
|
||||
}
|
||||
contact.threadID = thread.uniqueId
|
||||
contacts.insert(contact)
|
||||
}
|
||||
// Closed groups
|
||||
TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in
|
||||
guard let thread = object as? TSGroupThread, thread.isClosedGroup else { return }
|
||||
let memberSessionIDs = thread.groupModel.groupMemberIds
|
||||
memberSessionIDs.forEach { memberSessionID in
|
||||
guard !contacts.contains(where: { $0.sessionID == memberSessionID }) else { return }
|
||||
let contact = Contact(sessionID: memberSessionID)
|
||||
if let profile = OWSUserProfile.fetch(uniqueId: memberSessionID, transaction: transaction) {
|
||||
contact.name = profile.profileName
|
||||
contact.profilePictureURL = profile.avatarUrlPath
|
||||
contact.profilePictureFileName = profile.avatarFileName
|
||||
contact.profilePictureEncryptionKey = profile.profileKey
|
||||
}
|
||||
// At this point we know we don't have a one-on-one thread with this contact
|
||||
contacts.insert(contact)
|
||||
}
|
||||
}
|
||||
// Save
|
||||
contacts.forEach { contact in
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue