diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 50bb36752..221a73610 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -436,6 +436,7 @@ 4C4BC6C32102D697004040C9 /* ContactDiscoveryOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4BC6C22102D697004040C9 /* ContactDiscoveryOperationTest.swift */; }; 4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C63CBFF210A620B003AE45C /* SignalTSan.supp */; }; 4C6F527C20FFE8400097DEEE /* SignalUBSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */; }; + 4CA5F793211E1F06008C2708 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5F792211E1F06008C2708 /* Toast.swift */; }; 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */; }; 4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB5F26820F7D060004D1B42 /* MessageActions.swift */; }; 4CC0B59C20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC0B59B20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift */; }; @@ -1118,6 +1119,7 @@ 4C4BC6C22102D697004040C9 /* ContactDiscoveryOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ContactDiscoveryOperationTest.swift; path = contact/ContactDiscoveryOperationTest.swift; sourceTree = ""; }; 4C63CBFF210A620B003AE45C /* SignalTSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalTSan.supp; sourceTree = ""; }; 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalUBSan.supp; sourceTree = ""; }; + 4CA5F792211E1F06008C2708 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 4CB5F26820F7D060004D1B42 /* MessageActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions.swift; sourceTree = ""; }; 4CC0B59B20EC5F2E00CF6EE0 /* ConversationConfigurationSyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationConfigurationSyncOperation.swift; sourceTree = ""; }; 4CC1ECF8211A47CD00CC13BE /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; @@ -2229,6 +2231,7 @@ 450D19111F85236600970622 /* RemoteVideoView.h */, 450D19121F85236600970622 /* RemoteVideoView.m */, 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, + 4CA5F792211E1F06008C2708 /* Toast.swift */, ); name = Views; path = views; @@ -3390,6 +3393,7 @@ 34277A5E20751BDC006049F2 /* OWSQuotedMessageView.m in Sources */, 458DE9D61DEE3FD00071BB03 /* PeerConnectionClient.swift in Sources */, 45DDA6242090CEB500DE97F8 /* ConversationHeaderView.swift in Sources */, + 4CA5F793211E1F06008C2708 /* Toast.swift in Sources */, 45F32C242057297A00A300D5 /* MessageDetailViewController.swift in Sources */, 34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */, 457F671B20746193000EABCD /* QuotedReplyPreview.swift in Sources */, diff --git a/Signal/Images.xcassets/ic_broken_link.imageset/Contents.json b/Signal/Images.xcassets/ic_broken_link.imageset/Contents.json new file mode 100644 index 000000000..eeb397339 --- /dev/null +++ b/Signal/Images.xcassets/ic_broken_link.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "broken-link-16@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "broken-link-16@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "broken-link-16@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@1x.png b/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@1x.png new file mode 100644 index 000000000..366bc5a1b Binary files /dev/null and b/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@1x.png differ diff --git a/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@2x.png b/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@2x.png new file mode 100644 index 000000000..47bebd869 Binary files /dev/null and b/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@2x.png differ diff --git a/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@3x.png b/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@3x.png new file mode 100644 index 000000000..d792890a8 Binary files /dev/null and b/Signal/Images.xcassets/ic_broken_link.imageset/broken-link-16@3x.png differ diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSQuotedMessageView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSQuotedMessageView.m index 7aff79484..5d942877d 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSQuotedMessageView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSQuotedMessageView.m @@ -13,10 +13,13 @@ #import #import #import -#import NS_ASSUME_NONNULL_BEGIN +const CGFloat kRemotelySourcedContentGlyphLength = 16; +const CGFloat kRemotelySourcedContentRowMargin = 4; +const CGFloat kRemotelySourcedContentRowSpacing = 3; + @interface OWSQuotedMessageView () @property (nonatomic, readonly) OWSQuotedReplyModel *quotedMessage; @@ -29,6 +32,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) UILabel *quotedAuthorLabel; @property (nonatomic, readonly) UILabel *quotedTextLabel; +@property (nonatomic, readonly) UILabel *quoteContentSourceLabel; @end @@ -97,6 +101,7 @@ NS_ASSUME_NONNULL_BEGIN _quotedAuthorLabel = [UILabel new]; _quotedTextLabel = [UILabel new]; + _quoteContentSourceLabel = [UILabel new]; return self; } @@ -143,6 +148,11 @@ NS_ASSUME_NONNULL_BEGIN return 4.f; } +- (UIColor *)quoteBubbleBackgroundColor +{ + return [self.conversationStyle quotedReplyBubbleColorWithIsIncoming:!self.isOutgoing]; +} + - (void)createContents { // Ensure only called once. @@ -179,7 +189,7 @@ NS_ASSUME_NONNULL_BEGIN maskLayer.path = bezierPath.CGPath; }]; innerBubbleView.layer.mask = maskLayer; - innerBubbleView.backgroundColor = [self.conversationStyle quotedReplyBubbleColorWithIsIncoming:!self.isOutgoing]; + innerBubbleView.backgroundColor = self.quoteBubbleBackgroundColor; [self addSubview:innerBubbleView]; [innerBubbleView autoPinLeadingToSuperviewMarginWithInset:self.bubbleHMargin]; [innerBubbleView autoPinTrailingToSuperviewMarginWithInset:self.bubbleHMargin]; @@ -189,8 +199,6 @@ NS_ASSUME_NONNULL_BEGIN UIStackView *hStackView = [UIStackView new]; hStackView.axis = UILayoutConstraintAxisHorizontal; hStackView.spacing = self.hSpacing; - [innerBubbleView addSubview:hStackView]; - [hStackView ows_autoPinToSuperviewEdges]; UIView *stripeView = [UIView new]; stripeView.backgroundColor = [self.conversationStyle quotedReplyStripeColorWithIsIncoming:!self.isOutgoing]; @@ -278,6 +286,48 @@ NS_ASSUME_NONNULL_BEGIN [emptyView setContentHuggingHigh]; [emptyView autoSetDimension:ALDimensionWidth toSize:0.f]; } + + UIStackView *quoteSourceWrapper = [[UIStackView alloc] initWithArrangedSubviews:@[ hStackView ]]; + quoteSourceWrapper.axis = UILayoutConstraintAxisVertical; + + if (self.quotedMessage.isRemotelySourced) { + [quoteSourceWrapper addArrangedSubview:[self buildRemoteContentSourceView]]; + } + + [innerBubbleView addSubview:quoteSourceWrapper]; + [quoteSourceWrapper ows_autoPinToSuperviewEdges]; +} + +- (UIView *)buildRemoteContentSourceView +{ + UIImage *glyphImage = + [[UIImage imageNamed:@"ic_broken_link"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + OWSAssert(glyphImage); + OWSAssert(CGSizeEqualToSize( + CGSizeMake(kRemotelySourcedContentGlyphLength, kRemotelySourcedContentGlyphLength), glyphImage.size)); + UIImageView *glyphView = [[UIImageView alloc] initWithImage:glyphImage]; + glyphView.tintColor = Theme.secondaryColor; + [glyphView + autoSetDimensionsToSize:CGSizeMake(kRemotelySourcedContentGlyphLength, kRemotelySourcedContentGlyphLength)]; + + UILabel *label = [self configureQuoteContentSourceLabel]; + UIStackView *sourceRow = [[UIStackView alloc] initWithArrangedSubviews:@[ glyphView, label ]]; + sourceRow.axis = UILayoutConstraintAxisHorizontal; + sourceRow.alignment = UIStackViewAlignmentCenter; + // TODO verify spacing w/ design + sourceRow.spacing = kRemotelySourcedContentRowSpacing; + sourceRow.layoutMarginsRelativeArrangement = YES; + + const CGFloat leftMargin = 8; + sourceRow.layoutMargins = UIEdgeInsetsMake(kRemotelySourcedContentRowMargin, + leftMargin, + kRemotelySourcedContentRowMargin, + kRemotelySourcedContentRowMargin); + + UIColor *backgroundColor = [UIColor.whiteColor colorWithAlphaComponent:0.4]; + [sourceRow addBackgroundViewWithBackgroundColor:backgroundColor]; + + return sourceRow; } - (void)didTapFailedThumbnailDownload:(UITapGestureRecognizer *)gestureRecognizer @@ -367,6 +417,20 @@ NS_ASSUME_NONNULL_BEGIN return self.quotedTextLabel; } +- (UILabel *)configureQuoteContentSourceLabel +{ + OWSAssert(self.quoteContentSourceLabel); + + self.quoteContentSourceLabel.font = UIFont.ows_dynamicTypeFootnoteFont; + self.quoteContentSourceLabel.textColor = Theme.primaryColor; + self.quoteContentSourceLabel.text = NSLocalizedString(@"QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE", + @"Footer label that appears below quoted messages when the quoted content was note derived locally. When the " + @"local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead " + @"show the content specified by the sender."); + + return self.quoteContentSourceLabel; +} + - (nullable NSString *)fileTypeForSnippet { // TODO: Are we going to use the filename? For all mimetypes? @@ -487,6 +551,16 @@ NS_ASSUME_NONNULL_BEGIN textHeight += textSize.height; } + if (self.quotedMessage.isRemotelySourced) { + UILabel *quoteContentSourceLabel = [self configureQuoteContentSourceLabel]; + CGSize textSize = CGSizeCeil([quoteContentSourceLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); + CGFloat sourceStackViewHeight = MAX(kRemotelySourcedContentGlyphLength, textSize.height); + + textWidth + = MAX(textWidth, textSize.width + kRemotelySourcedContentGlyphLength + kRemotelySourcedContentRowSpacing); + result.height += kRemotelySourcedContentRowMargin * 2 + sourceStackViewHeight; + } + textWidth = MIN(textWidth, maxTextWidth); result.width += textWidth; result.height += MAX(textHeight, thumbnailHeight); diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 78abfaee7..b6ad45215 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -2433,7 +2433,7 @@ typedef enum : NSUInteger { }]; if (!quotedInteraction || !groupIndex) { - DDLogError(@"%@ Couldn't find message quoted in quoted reply.", self.logTag); + [self presentMissingQuotedReplyToast]; return; } @@ -2534,7 +2534,8 @@ typedef enum : NSUInteger { __block OWSQuotedReplyModel *quotedReply; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - quotedReply = [OWSQuotedReplyModel quotedReplyForConversationViewItem:conversationItem transaction:transaction]; + quotedReply = [OWSQuotedReplyModel quotedReplyForSendingWithConversationViewItem:conversationItem + transaction:transaction]; }]; if (![quotedReply isKindOfClass:[OWSQuotedReplyModel class]]) { @@ -5292,6 +5293,23 @@ typedef enum : NSUInteger { [self dismissViewControllerAnimated:YES completion:nil]; } +#pragma mark - Toast + +- (void)presentMissingQuotedReplyToast +{ + DDLogInfo(@"%@ in %s", self.logTag, __PRETTY_FUNCTION__); + + NSString *toastText = NSLocalizedString(@"QUOTED_REPLY_MISSING_ORIGINAL_MESSAGE", + @"Toast alert text shown when tapping on a quoted message which we cannot scroll to, because the local copy of " + @"the message doesn't exist."); + + ToastController *toastController = [[ToastController alloc] initWithText:toastText]; + + CGFloat bottomInset = 10 + self.collectionView.contentInset.bottom + self.view.layoutMargins.bottom; + + [toastController presentToastViewFromBottomOfView:self.view inset:bottomInset]; +} + #pragma mark - - (void)presentViewController:(UIViewController *)viewController diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index c1729513d..bef6641b5 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -539,7 +539,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) if (message.quotedMessage) { self.quotedReply = - [[OWSQuotedReplyModel alloc] initWithQuotedMessage:message.quotedMessage transaction:transaction]; + [OWSQuotedReplyModel quotedReplyWithQuotedMessage:message.quotedMessage transaction:transaction]; if (self.quotedReply.body.length > 0) { self.displayableQuotedText = diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m index 773004a77..76a9b3862 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m @@ -1995,7 +1995,9 @@ NS_ASSUME_NONNULL_BEGIN isGroupThread:thread.isGroupThread transaction:transaction conversationStyle:conversationStyle]; - quotedMessage = [[OWSQuotedReplyModel quotedReplyForConversationViewItem:viewItem transaction:transaction] buildQuotedMessage]; + quotedMessage = [ + [OWSQuotedReplyModel quotedReplyForSendingWithConversationViewItem:viewItem transaction:transaction] + buildQuotedMessageForSending]; } else { TSOutgoingMessage *_Nullable messageToQuote = [self createFakeOutgoingMessage:thread messageBody:quotedMessageBodyWIndex @@ -2012,7 +2014,9 @@ NS_ASSUME_NONNULL_BEGIN isGroupThread:thread.isGroupThread transaction:transaction conversationStyle:conversationStyle]; - quotedMessage = [[OWSQuotedReplyModel quotedReplyForConversationViewItem:viewItem transaction:transaction] buildQuotedMessage]; + quotedMessage = [ + [OWSQuotedReplyModel quotedReplyForSendingWithConversationViewItem:viewItem transaction:transaction] + buildQuotedMessageForSending]; } OWSAssert(quotedMessage); diff --git a/Signal/src/views/Toast.swift b/Signal/src/views/Toast.swift new file mode 100644 index 000000000..c9fa543c7 --- /dev/null +++ b/Signal/src/views/Toast.swift @@ -0,0 +1,142 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +protocol ToastViewDelegate: class { + func didTapToastView(_ toastView: ToastView) + func didSwipeToastView(_ toastView: ToastView) +} + +class ToastView: UIView { + + var text: String? { + get { + return label.text + } + set { + label.text = newValue + } + } + weak var delegate: ToastViewDelegate? + + private let label: UILabel + + // MARK: Initializers + + override init(frame: CGRect) { + label = UILabel() + super.init(frame: frame) + + self.layer.cornerRadius = 4 + self.backgroundColor = Theme.toastBackgroundColor + self.layoutMargins = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + + label.textAlignment = .center + label.textColor = Theme.toastForegroundColor + label.font = UIFont.ows_dynamicTypeBody + label.numberOfLines = 0 + self.addSubview(label) + label.autoPinEdgesToSuperviewMargins() + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap(gesture:))) + self.addGestureRecognizer(tapGesture) + + let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipe(gesture:))) + self.addGestureRecognizer(swipeGesture) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Gestures + + @objc + func didTap(gesture: UITapGestureRecognizer) { + self.delegate?.didTapToastView(self) + } + + @objc + func didSwipe(gesture: UISwipeGestureRecognizer) { + self.delegate?.didSwipeToastView(self) + } +} + +@objc +class ToastController: NSObject, ToastViewDelegate { + + private let toastView: ToastView + private var isDismissing: Bool + + // MARK: Initializers + + @objc + required init(text: String) { + toastView = ToastView() + toastView.text = text + isDismissing = false + + super.init() + + toastView.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Public + + @objc + func presentToastView(fromBottomOfView view: UIView, inset: CGFloat) { + Logger.debug("\(logTag) in \(#function)") + toastView.alpha = 0 + view.addSubview(toastView) + toastView.setCompressionResistanceHigh() + toastView.autoPinEdge(.bottom, to: .bottom, of: view, withOffset: -inset) + toastView.autoPinWidthToSuperview(withMargin: 24) + + UIView.animate(withDuration: 0.1) { + self.toastView.alpha = 1 + } + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.5) { + // intentional strong reference to self. + // As with an AlertController, the caller likely expects toast to + // be presented and dismissed without maintaining a strong reference to ToastController + self.dismissToastView() + } + } + + // MARK: ToastViewDelegate + + func didTapToastView(_ toastView: ToastView) { + Logger.debug("\(logTag) in \(#function)") + self.dismissToastView() + } + + func didSwipeToastView(_ toastView: ToastView) { + Logger.debug("\(logTag) in \(#function)") + self.dismissToastView() + } + + // MARK: Internal + + func dismissToastView() { + Logger.debug("\(logTag) in \(#function)") + + guard !isDismissing else { + return + } + isDismissing = true + UIView.animate(withDuration: 0.1, + animations: { + self.toastView.alpha = 0 + }, + completion: { (_) in + self.toastView.removeFromSuperview() + }) + } +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index a58f1aa27..3e527c118 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1607,6 +1607,12 @@ /* message header label when quoting yourself */ "QUOTED_REPLY_AUTHOR_INDICATOR_YOURSELF" = "Replying to Yourself"; +/* Footer label that appears below quoted messages when the quoted content was note derived locally. When the local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead show the content specified by the sender. */ +"QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE" = "Original message not found."; + +/* Toast alert text shown when tapping on a quoted message which we cannot scroll to, because the local copy of the message doesn't exist. */ +"QUOTED_REPLY_MISSING_ORIGINAL_MESSAGE" = "Original message not found."; + /* Indicates this message is a quoted reply to an attachment of unknown type. */ "QUOTED_REPLY_TYPE_ATTACHMENT" = "Attachment"; diff --git a/SignalMessaging/ViewModels/OWSQuotedReplyModel.h b/SignalMessaging/ViewModels/OWSQuotedReplyModel.h index cbfcfeb4c..156b45d7a 100644 --- a/SignalMessaging/ViewModels/OWSQuotedReplyModel.h +++ b/SignalMessaging/ViewModels/OWSQuotedReplyModel.h @@ -2,15 +2,16 @@ // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // +#import + +NS_ASSUME_NONNULL_BEGIN + @class ConversationViewItem; @class TSAttachmentPointer; @class TSAttachmentStream; @class TSMessage; -@class TSQuotedMessage; @class YapDatabaseReadTransaction; -NS_ASSUME_NONNULL_BEGIN - // View model which has already fetched any attachments. @interface OWSQuotedReplyModel : NSObject @@ -23,6 +24,7 @@ NS_ASSUME_NONNULL_BEGIN // This property should be set IFF we are quoting a text message // or attachment with caption. @property (nullable, nonatomic, readonly) NSString *body; +@property (nonatomic, readonly) BOOL isRemotelySourced; #pragma mark - Attachments @@ -33,27 +35,17 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, nullable) NSString *sourceFilename; @property (nonatomic, readonly, nullable) UIImage *thumbnailImage; -// Convenience initializer for building an outgoing quoted reply preview, before it's sent -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - attachmentStream:(nullable TSAttachmentStream *)attachment; //TODO quotedAttachmentStream? - -// Convenience initializer for building an outgoing quoted reply preview, before it's sent -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - thumbnailImage:(nullable UIImage *)thumbnailImage; +- (instancetype)init NS_UNAVAILABLE; // Used for persisted quoted replies, both incoming and outgoing. -- (instancetype)initWithQuotedMessage:(TSQuotedMessage *)quotedMessage - transaction:(YapDatabaseReadTransaction *)transaction; ++ (instancetype)quotedReplyWithQuotedMessage:(TSQuotedMessage *)quotedMessage + transaction:(YapDatabaseReadTransaction *)transaction; // Builds a not-yet-sent QuotedReplyModel -+ (nullable instancetype)quotedReplyForConversationViewItem:(ConversationViewItem *)conversationItem - transaction:(YapDatabaseReadTransaction *)transaction; ++ (nullable instancetype)quotedReplyForSendingWithConversationViewItem:(ConversationViewItem *)conversationItem + transaction:(YapDatabaseReadTransaction *)transaction; -- (TSQuotedMessage *)buildQuotedMessage; +- (TSQuotedMessage *)buildQuotedMessageForSending; @end diff --git a/SignalMessaging/ViewModels/OWSQuotedReplyModel.m b/SignalMessaging/ViewModels/OWSQuotedReplyModel.m index ef1c01cce..ad8d578e3 100644 --- a/SignalMessaging/ViewModels/OWSQuotedReplyModel.m +++ b/SignalMessaging/ViewModels/OWSQuotedReplyModel.m @@ -16,43 +16,64 @@ #import #import +NS_ASSUME_NONNULL_BEGIN + +@interface OWSQuotedReplyModel () + +@property (nonatomic, readonly) TSQuotedMessageContentSource bodySource; + +- (instancetype)initWithTimestamp:(uint64_t)timestamp + authorId:(NSString *)authorId + body:(nullable NSString *)body + bodySource:(TSQuotedMessageContentSource)bodySource + thumbnailImage:(nullable UIImage *)thumbnailImage + contentType:(nullable NSString *)contentType + sourceFilename:(nullable NSString *)sourceFilename + attachmentStream:(nullable TSAttachmentStream *)attachmentStream + thumbnailAttachmentPointer:(nullable TSAttachmentPointer *)thumbnailAttachmentPointer + thumbnailDownloadFailed:(BOOL)thumbnailDownloadFailed NS_DESIGNATED_INITIALIZER; + +@end + // View Model which has already fetched any thumbnail attachment. @implementation OWSQuotedReplyModel +#pragma mark - Initializers + - (instancetype)initWithTimestamp:(uint64_t)timestamp authorId:(NSString *)authorId - body:(NSString *_Nullable)body + body:(nullable NSString *)body + bodySource:(TSQuotedMessageContentSource)bodySource + thumbnailImage:(nullable UIImage *)thumbnailImage + contentType:(nullable NSString *)contentType + sourceFilename:(nullable NSString *)sourceFilename attachmentStream:(nullable TSAttachmentStream *)attachmentStream + thumbnailAttachmentPointer:(nullable TSAttachmentPointer *)thumbnailAttachmentPointer + thumbnailDownloadFailed:(BOOL)thumbnailDownloadFailed { - return [self initWithTimestamp:timestamp - authorId:authorId - body:body - thumbnailImage:attachmentStream.thumbnailImage - contentType:attachmentStream.contentType - sourceFilename:attachmentStream.sourceFilename - attachmentStream:attachmentStream - thumbnailAttachmentPointer:nil - thumbnailDownloadFailed:NO]; + self = [super init]; + if (!self) { + return self; + } + + _timestamp = timestamp; + _authorId = authorId; + _body = body; + _bodySource = bodySource; + _thumbnailImage = thumbnailImage; + _contentType = contentType; + _sourceFilename = sourceFilename; + _attachmentStream = attachmentStream; + _thumbnailAttachmentPointer = thumbnailAttachmentPointer; + _thumbnailDownloadFailed = thumbnailDownloadFailed; + + return self; } -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(NSString *_Nullable)body - thumbnailImage:(nullable UIImage *)thumbnailImage; -{ - return [self initWithTimestamp:timestamp - authorId:authorId - body:body - thumbnailImage:thumbnailImage - contentType:nil - sourceFilename:nil - attachmentStream:nil - thumbnailAttachmentPointer:nil - thumbnailDownloadFailed:NO]; -} +#pragma mark - Factory Methods -- (instancetype)initWithQuotedMessage:(TSQuotedMessage *)quotedMessage - transaction:(YapDatabaseReadTransaction *)transaction ++ (instancetype)quotedReplyWithQuotedMessage:(TSQuotedMessage *)quotedMessage + transaction:(YapDatabaseReadTransaction *)transaction { OWSAssert(quotedMessage.quotedAttachments.count <= 1); OWSAttachmentInfo *attachmentInfo = quotedMessage.quotedAttachments.firstObject; @@ -82,57 +103,20 @@ } } - return [self initWithTimestamp:quotedMessage.timestamp - authorId:quotedMessage.authorId - body:quotedMessage.body - thumbnailImage:thumbnailImage - contentType:attachmentInfo.contentType - sourceFilename:attachmentInfo.sourceFilename - attachmentStream:nil - thumbnailAttachmentPointer:attachmentPointer - thumbnailDownloadFailed:thumbnailDownloadFailed]; + return [[self alloc] initWithTimestamp:quotedMessage.timestamp + authorId:quotedMessage.authorId + body:quotedMessage.body + bodySource:quotedMessage.bodySource + thumbnailImage:thumbnailImage + contentType:attachmentInfo.contentType + sourceFilename:attachmentInfo.sourceFilename + attachmentStream:nil + thumbnailAttachmentPointer:attachmentPointer + thumbnailDownloadFailed:thumbnailDownloadFailed]; } -- (instancetype)initWithTimestamp:(uint64_t)timestamp - authorId:(NSString *)authorId - body:(nullable NSString *)body - thumbnailImage:(nullable UIImage *)thumbnailImage - contentType:(nullable NSString *)contentType - sourceFilename:(nullable NSString *)sourceFilename - attachmentStream:(nullable TSAttachmentStream *)attachmentStream - thumbnailAttachmentPointer:(nullable TSAttachmentPointer *)thumbnailAttachmentPointer - thumbnailDownloadFailed:(BOOL)thumbnailDownloadFailed -{ - self = [super init]; - if (!self) { - return self; - } - - _timestamp = timestamp; - _authorId = authorId; - _body = body; - _thumbnailImage = thumbnailImage; - _contentType = contentType; - _sourceFilename = sourceFilename; - _attachmentStream = attachmentStream; - _thumbnailAttachmentPointer = thumbnailAttachmentPointer; - _thumbnailDownloadFailed = thumbnailDownloadFailed; - - return self; -} - -- (TSQuotedMessage *)buildQuotedMessage -{ - NSArray *attachments = self.attachmentStream ? @[ self.attachmentStream ] : @[]; - - return [[TSQuotedMessage alloc] initWithTimestamp:self.timestamp - authorId:self.authorId - body:self.body - quotedAttachmentsForSending:attachments]; -} - -+ (nullable instancetype)quotedReplyForConversationViewItem:(ConversationViewItem *)conversationItem - transaction:(YapDatabaseReadTransaction *)transaction; ++ (nullable instancetype)quotedReplyForSendingWithConversationViewItem:(ConversationViewItem *)conversationItem + transaction:(YapDatabaseReadTransaction *)transaction; { OWSAssert(conversationItem); OWSAssert(transaction); @@ -167,11 +151,16 @@ // because the QuotedReplyViewModel has some hardcoded assumptions that only quoted attachments have // thumbnails. Until we address that we want to be consistent about neither showing nor sending the // contactShare avatar in the quoted reply. - return [[OWSQuotedReplyModel alloc] initWithTimestamp:timestamp - authorId:authorId - body:[@"👤 " stringByAppendingString:contactShare.displayName] - thumbnailImage:nil]; - + return [[self alloc] initWithTimestamp:timestamp + authorId:authorId + body:[@"👤 " stringByAppendingString:contactShare.displayName] + bodySource:TSQuotedMessageContentSourceLocal + thumbnailImage:nil + contentType:nil + sourceFilename:nil + attachmentStream:nil + thumbnailAttachmentPointer:nil + thumbnailDownloadFailed:NO]; } NSString *_Nullable quotedText = message.body; @@ -234,11 +223,35 @@ hasText = YES; } - return [[OWSQuotedReplyModel alloc] initWithTimestamp:timestamp - authorId:authorId - body:quotedText - attachmentStream:quotedAttachment]; + return [[self alloc] initWithTimestamp:timestamp + authorId:authorId + body:quotedText + bodySource:TSQuotedMessageContentSourceLocal + thumbnailImage:quotedAttachment.thumbnailImage + contentType:quotedAttachment.contentType + sourceFilename:quotedAttachment.sourceFilename + attachmentStream:quotedAttachment + thumbnailAttachmentPointer:nil + thumbnailDownloadFailed:NO]; } +#pragma mark - Instance Methods + +- (TSQuotedMessage *)buildQuotedMessageForSending +{ + NSArray *attachments = self.attachmentStream ? @[ self.attachmentStream ] : @[]; + + return [[TSQuotedMessage alloc] initWithTimestamp:self.timestamp + authorId:self.authorId + body:self.body + quotedAttachmentsForSending:attachments]; +} + +- (BOOL)isRemotelySourced +{ + return self.bodySource == TSQuotedMessageContentSourceRemote; +} @end + +NS_ASSUME_NONNULL_END diff --git a/SignalMessaging/categories/Theme.h b/SignalMessaging/categories/Theme.h index fc13568c7..95cbd1343 100644 --- a/SignalMessaging/categories/Theme.h +++ b/SignalMessaging/categories/Theme.h @@ -49,6 +49,11 @@ extern NSString *const ThemeDidChangeNotification; @property (class, readonly, nonatomic) UIColor *searchBarBackgroundColor; @property (class, readonly, nonatomic) UIBlurEffect *barBlurEffect; +#pragma mark - + +@property (class, readonly, nonatomic) UIColor *toastForegroundColor; +@property (class, readonly, nonatomic) UIColor *toastBackgroundColor; + @end NS_ASSUME_NONNULL_END diff --git a/SignalMessaging/categories/Theme.m b/SignalMessaging/categories/Theme.m index b94b0b907..c35cdf208 100644 --- a/SignalMessaging/categories/Theme.m +++ b/SignalMessaging/categories/Theme.m @@ -154,6 +154,18 @@ NSString *const ThemeKeyThemeEnabled = @"ThemeKeyThemeEnabled"; return Theme.backgroundColor; } +#pragma mark - + ++ (UIColor *)toastForegroundColor +{ + return (Theme.isDarkThemeEnabled ? UIColor.ows_whiteColor : UIColor.ows_whiteColor); +} + ++ (UIColor *)toastBackgroundColor +{ + return (Theme.isDarkThemeEnabled ? UIColor.ows_dark60Color : UIColor.ows_light60Color); +} + @end NS_ASSUME_NONNULL_END diff --git a/SignalMessaging/utils/ThreadUtil.m b/SignalMessaging/utils/ThreadUtil.m index de8902bcf..d256c7a7b 100644 --- a/SignalMessaging/utils/ThreadUtil.m +++ b/SignalMessaging/utils/ThreadUtil.m @@ -84,11 +84,12 @@ NS_ASSUME_NONNULL_BEGIN OWSDisappearingMessagesConfiguration *configuration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:thread.uniqueId]; uint32_t expiresInSeconds = (configuration.isEnabled ? configuration.durationSeconds : 0); - TSOutgoingMessage *message = [TSOutgoingMessage outgoingMessageInThread:thread - messageBody:text - attachmentId:nil - expiresInSeconds:expiresInSeconds - quotedMessage:[quotedReplyModel buildQuotedMessage]]; + TSOutgoingMessage *message = + [TSOutgoingMessage outgoingMessageInThread:thread + messageBody:text + attachmentId:nil + expiresInSeconds:expiresInSeconds + quotedMessage:[quotedReplyModel buildQuotedMessageForSending]]; [messageSender enqueueMessage:message success:successHandler failure:failureHandler]; @@ -136,7 +137,7 @@ NS_ASSUME_NONNULL_BEGIN expireStartedAt:0 isVoiceMessage:[attachment isVoiceMessage] groupMetaMessage:TSGroupMessageUnspecified - quotedMessage:[quotedReplyModel buildQuotedMessage] + quotedMessage:[quotedReplyModel buildQuotedMessageForSending] contactShare:nil]; [messageSender enqueueAttachment:attachment.dataSource diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.h b/SignalServiceKit/src/Messages/Interactions/TSMessage.h index c0e7219ce..869ccdc41 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.h @@ -44,6 +44,7 @@ NS_ASSUME_NONNULL_BEGIN - (nullable TSAttachment *)attachmentWithTransaction:(YapDatabaseReadTransaction *)transaction; - (void)setQuotedMessageThumbnailAttachmentStream:(TSAttachmentStream *)attachmentStream; +- (nullable NSString *)bodyTextWithTransaction:(YapDatabaseReadTransaction *)transaction; - (BOOL)shouldStartExpireTimerWithTransaction:(YapDatabaseReadTransaction *)transaction; diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.m b/SignalServiceKit/src/Messages/Interactions/TSMessage.m index 857dc0ca3..314eea2c8 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.m @@ -226,6 +226,32 @@ static const NSUInteger OWSMessageSchemaVersion = 4; } } +- (nullable NSString *)bodyTextWithTransaction:(YapDatabaseReadTransaction *)transaction +{ + if (self.hasAttachments) { + TSAttachment *_Nullable attachment = [self attachmentWithTransaction:transaction]; + + if ([OWSMimeTypeOversizeTextMessage isEqualToString:attachment.contentType] && + [attachment isKindOfClass:TSAttachmentStream.class]) { + TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; + + NSData *_Nullable data = [NSData dataWithContentsOfFile:attachmentStream.filePath]; + if (data) { + NSString *_Nullable text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (text) { + return text.filterStringForDisplay; + } + } + } + } + + if (self.body.length > 0) { + return self.body.filterStringForDisplay; + } + + return nil; +} + // TODO: This method contains view-specific logic and probably belongs in NotificationsManager, not in SSK. - (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction { diff --git a/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.h b/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.h index 1f85b2900..ba9445938 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.h @@ -41,10 +41,17 @@ NS_ASSUME_NONNULL_BEGIN @end +typedef NS_ENUM(NSUInteger, TSQuotedMessageContentSource) { + TSQuotedMessageContentSourceUnknown, + TSQuotedMessageContentSourceLocal, + TSQuotedMessageContentSourceRemote +}; + @interface TSQuotedMessage : MTLModel @property (nonatomic, readonly) uint64_t timestamp; @property (nonatomic, readonly) NSString *authorId; +@property (nonatomic, readonly) TSQuotedMessageContentSource bodySource; // This property should be set IFF we are quoting a text message // or attachment with caption. @@ -80,6 +87,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithTimestamp:(uint64_t)timestamp authorId:(NSString *)authorId body:(NSString *_Nullable)body + bodySource:(TSQuotedMessageContentSource)bodySource receivedQuotedAttachmentInfos:(NSArray *)attachmentInfos; // used when sending quoted messages diff --git a/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.m b/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.m index 182481e6c..84a02bc1f 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSQuotedMessage.m @@ -59,6 +59,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithTimestamp:(uint64_t)timestamp authorId:(NSString *)authorId body:(NSString *_Nullable)body + bodySource:(TSQuotedMessageContentSource)bodySource receivedQuotedAttachmentInfos:(NSArray *)attachmentInfos { OWSAssert(timestamp > 0); @@ -72,6 +73,7 @@ NS_ASSUME_NONNULL_BEGIN _timestamp = timestamp; _authorId = authorId; _body = body; + _bodySource = bodySource; _quotedAttachments = attachmentInfos; return self; @@ -93,7 +95,8 @@ NS_ASSUME_NONNULL_BEGIN _timestamp = timestamp; _authorId = authorId; _body = body; - + _bodySource = TSQuotedMessageContentSourceLocal; + NSMutableArray *attachmentInfos = [NSMutableArray new]; for (TSAttachmentStream *attachmentStream in attachments) { [attachmentInfos addObject:[[OWSAttachmentInfo alloc] initWithAttachmentStream:attachmentStream]]; @@ -129,13 +132,32 @@ NS_ASSUME_NONNULL_BEGIN NSString *authorId = [quoteProto author]; NSString *_Nullable body = nil; - BOOL hasText = NO; BOOL hasAttachment = NO; - if ([quoteProto hasText] && [quoteProto text].length > 0) { - body = [quoteProto text]; - hasText = YES; + TSQuotedMessageContentSource bodySource = TSQuotedMessageContentSourceUnknown; + + // Prefer to generate the text snippet locally if available. + TSMessage *_Nullable localRecord = (TSMessage *)[ + [TSInteraction interactionsWithTimestamp:quoteProto.id ofClass:TSMessage.class withTransaction:transaction] + firstObject]; + + if (localRecord) { + bodySource = TSQuotedMessageContentSourceLocal; + + NSString *localText = [localRecord bodyTextWithTransaction:transaction]; + if (localText.length > 0) { + body = localText; + } } + if (body.length == 0) { + if (quoteProto.text.length > 0) { + bodySource = TSQuotedMessageContentSourceRemote; + body = quoteProto.text; + } + } + + OWSAssert(bodySource != TSQuotedMessageContentSourceUnknown); + NSMutableArray *attachmentInfos = [NSMutableArray new]; for (SSKProtoDataMessageQuoteQuotedAttachment *quotedAttachment in quoteProto.attachments) { hasAttachment = YES; @@ -180,7 +202,7 @@ NS_ASSUME_NONNULL_BEGIN [attachmentInfos addObject:attachmentInfo]; } - if (!hasText && !hasAttachment) { + if (body.length == 0 && !hasAttachment) { OWSFail(@"%@ quoted message has neither text nor attachment", self.logTag); return nil; } @@ -188,6 +210,7 @@ NS_ASSUME_NONNULL_BEGIN return [[TSQuotedMessage alloc] initWithTimestamp:timestamp authorId:authorId body:body + bodySource:bodySource receivedQuotedAttachmentInfos:attachmentInfos]; }