// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import #import "ConversationViewItem.h" #import "OWSMessageCell.h" #import "OWSMessageHeaderView.h" #import "OWSSystemMessageCell.h" #import "Session-Swift.h" #import "AnyPromise.h" #import #import #import #import #import #import NS_ASSUME_NONNULL_BEGIN NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) { switch (cellType) { case OWSMessageCellType_TextOnlyMessage: return @"OWSMessageCellType_TextOnlyMessage"; case OWSMessageCellType_Audio: return @"OWSMessageCellType_Audio"; case OWSMessageCellType_GenericAttachment: return @"OWSMessageCellType_GenericAttachment"; case OWSMessageCellType_Unknown: return @"OWSMessageCellType_Unknown"; case OWSMessageCellType_MediaMessage: return @"OWSMessageCellType_MediaMessage"; case OWSMessageCellType_OversizeTextDownloading: return @"OWSMessageCellType_OversizeTextDownloading"; } } #pragma mark - @implementation ConversationMediaAlbumItem - (instancetype)initWithAttachment:(TSAttachment *)attachment attachmentStream:(nullable TSAttachmentStream *)attachmentStream caption:(nullable NSString *)caption mediaSize:(CGSize)mediaSize { OWSAssertDebug(attachment); self = [super init]; if (!self) { return self; } _attachment = attachment; _attachmentStream = attachmentStream; _caption = caption; _mediaSize = mediaSize; return self; } - (BOOL)isFailedDownload { if (![self.attachment isKindOfClass:[TSAttachmentPointer class]]) { return NO; } TSAttachmentPointer *attachmentPointer = (TSAttachmentPointer *)self.attachment; return attachmentPointer.state == TSAttachmentPointerStateFailed; } @end #pragma mark - @interface ConversationInteractionViewItem () @property (nonatomic, nullable) NSValue *cachedCellSize; #pragma mark - OWSAudioPlayerDelegate @property (nonatomic) AudioPlaybackState audioPlaybackState; @property (nonatomic) CGFloat audioProgressSeconds; @property (nonatomic) CGFloat audioDurationSeconds; #pragma mark - View State @property (nonatomic) BOOL hasViewState; @property (nonatomic) OWSMessageCellType messageCellType; @property (nonatomic, nullable) DisplayableText *displayableBodyText; @property (nonatomic, nullable) DisplayableText *displayableQuotedText; @property (nonatomic, nullable) OWSQuotedReplyModel *quotedReply; @property (nonatomic, nullable) TSAttachmentStream *attachmentStream; @property (nonatomic, nullable) TSAttachmentPointer *attachmentPointer; @property (nonatomic, nullable) ContactShareViewModel *contactShare; @property (nonatomic, nullable) OWSLinkPreview *linkPreview; @property (nonatomic, nullable) TSAttachment *linkPreviewAttachment; @property (nonatomic, nullable) NSArray *mediaAlbumItems; @property (nonatomic, nullable) NSString *systemMessageText; @property (nonatomic, nullable) TSThread *incomingMessageAuthorThread; @property (nonatomic, nullable) NSString *authorConversationColorName; @property (nonatomic, nullable) ConversationStyle *conversationStyle; @end #pragma mark - @implementation ConversationInteractionViewItem @synthesize shouldShowDate = _shouldShowDate; @synthesize shouldShowSenderAvatar = _shouldShowSenderAvatar; @synthesize unreadIndicator = _unreadIndicator; @synthesize didCellMediaFailToLoad = _didCellMediaFailToLoad; @synthesize interaction = _interaction; @synthesize isFirstInCluster = _isFirstInCluster; @synthesize isGroupThread = _isGroupThread; @synthesize isLastInCluster = _isLastInCluster; @synthesize lastAudioMessageView = _lastAudioMessageView; @synthesize senderName = _senderName; @synthesize shouldHideFooter = _shouldHideFooter; - (instancetype)initWithInteraction:(TSInteraction *)interaction isGroupThread:(BOOL)isGroupThread transaction:(YapDatabaseReadTransaction *)transaction conversationStyle:(ConversationStyle *)conversationStyle { OWSAssertDebug(interaction); OWSAssertDebug(transaction); OWSAssertDebug(conversationStyle); self = [super init]; if (!self) { return self; } _interaction = interaction; _isGroupThread = isGroupThread; _conversationStyle = conversationStyle; [self ensureViewState:transaction]; return self; } - (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction { OWSAssertDebug(interaction); _interaction = interaction; self.hasViewState = NO; self.messageCellType = OWSMessageCellType_Unknown; self.displayableBodyText = nil; self.attachmentStream = nil; self.attachmentPointer = nil; self.mediaAlbumItems = nil; self.displayableQuotedText = nil; self.quotedReply = nil; self.contactShare = nil; self.systemMessageText = nil; self.authorConversationColorName = nil; self.linkPreview = nil; self.linkPreviewAttachment = nil; [self clearCachedLayoutState]; [self ensureViewState:transaction]; } - (OWSPrimaryStorage *)primaryStorage { return SSKEnvironment.shared.primaryStorage; } - (NSString *)itemId { return self.interaction.uniqueId; } - (BOOL)hasBodyText { return _displayableBodyText != nil; } - (BOOL)hasQuotedText { return _displayableQuotedText != nil; } - (BOOL)hasQuotedAttachment { return self.quotedAttachmentMimetype.length > 0; } - (BOOL)isQuotedReply { return self.hasQuotedAttachment || self.hasQuotedText; } - (BOOL)isExpiringMessage { if (self.interaction.interactionType != OWSInteractionType_OutgoingMessage && self.interaction.interactionType != OWSInteractionType_IncomingMessage) { return NO; } TSMessage *message = (TSMessage *)self.interaction; return message.isExpiringMessage; } - (BOOL)hasCellHeader { return self.shouldShowDate || self.unreadIndicator; } - (void)setShouldShowDate:(BOOL)shouldShowDate { if (_shouldShowDate == shouldShowDate) { return; } _shouldShowDate = shouldShowDate; [self clearCachedLayoutState]; } - (void)setShouldShowSenderAvatar:(BOOL)shouldShowSenderAvatar { if (_shouldShowSenderAvatar == shouldShowSenderAvatar) { return; } _shouldShowSenderAvatar = shouldShowSenderAvatar; [self clearCachedLayoutState]; } - (void)setSenderName:(nullable NSAttributedString *)senderName { if ([NSObject isNullableObject:senderName equalTo:_senderName]) { return; } _senderName = senderName; [self clearCachedLayoutState]; } - (void)setShouldHideFooter:(BOOL)shouldHideFooter { if (_shouldHideFooter == shouldHideFooter) { return; } _shouldHideFooter = shouldHideFooter; [self clearCachedLayoutState]; } - (void)setIsFirstInCluster:(BOOL)isFirstInCluster { if (_isFirstInCluster == isFirstInCluster) { return; } _isFirstInCluster = isFirstInCluster; // Although this doesn't affect layout size, the view model use // hasCachedLayoutState to detect which cells needs to be redrawn due to changes. [self clearCachedLayoutState]; } - (void)setIsLastInCluster:(BOOL)isLastInCluster { if (_isLastInCluster == isLastInCluster) { return; } _isLastInCluster = isLastInCluster; // Although this doesn't affect layout size, the view model use // hasCachedLayoutState to detect which cells needs to be redrawn due to changes. [self clearCachedLayoutState]; } - (void)setUnreadIndicator:(nullable OWSUnreadIndicator *)unreadIndicator { if ([NSObject isNullableObject:_unreadIndicator equalTo:unreadIndicator]) { return; } _unreadIndicator = unreadIndicator; [self clearCachedLayoutState]; } - (void)clearCachedLayoutState { self.cachedCellSize = nil; } - (BOOL)hasCachedLayoutState { return self.cachedCellSize != nil; } - (CGSize)cellSize { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationStyle); if (!self.cachedCellSize) { ConversationViewCell *_Nullable measurementCell = [self measurementCell]; measurementCell.viewItem = self; measurementCell.conversationStyle = self.conversationStyle; CGSize cellSize = [measurementCell cellSize]; self.cachedCellSize = [NSValue valueWithCGSize:cellSize]; [measurementCell prepareForReuse]; } return [self.cachedCellSize CGSizeValue]; } - (nullable ConversationViewCell *)measurementCell { OWSAssertIsOnMainThread(); OWSAssertDebug(self.interaction); // For performance reasons, we cache one instance of each kind of // cell and uses these cells for measurement. static NSMutableDictionary *measurementCellCache = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ measurementCellCache = [NSMutableDictionary new]; }); NSNumber *cellCacheKey = @(self.interaction.interactionType); ConversationViewCell *_Nullable measurementCell = measurementCellCache[cellCacheKey]; if (!measurementCell) { switch (self.interaction.interactionType) { case OWSInteractionType_Unknown: OWSFailDebug(@"Unknown interaction type."); return nil; case OWSInteractionType_IncomingMessage: case OWSInteractionType_OutgoingMessage: measurementCell = [OWSMessageCell new]; break; case OWSInteractionType_Error: case OWSInteractionType_Info: case OWSInteractionType_Call: measurementCell = [OWSSystemMessageCell new]; break; case OWSInteractionType_TypingIndicator: measurementCell = [OWSTypingIndicatorCell new]; break; } OWSAssertDebug(measurementCell); measurementCellCache[cellCacheKey] = measurementCell; } return measurementCell; } - (CGFloat)vSpacingWithPreviousLayoutItem:(id)previousLayoutItem { OWSAssertDebug(previousLayoutItem); if (self.hasCellHeader) { return OWSMessageHeaderViewDateHeaderVMargin; } // "Bubble Collapse". Adjacent messages with the same author should be close together. if (self.interaction.interactionType == OWSInteractionType_IncomingMessage && previousLayoutItem.interaction.interactionType == OWSInteractionType_IncomingMessage) { TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction; TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousLayoutItem.interaction; if ([incomingMessage.authorId isEqualToString:previousIncomingMessage.authorId]) { return 2.f; } } else if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage && previousLayoutItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { return 2.f; } return 12.f; } - (ConversationViewCell *)dequeueCellForCollectionView:(UICollectionView *)collectionView indexPath:(NSIndexPath *)indexPath { OWSAssertIsOnMainThread(); OWSAssertDebug(collectionView); OWSAssertDebug(indexPath); OWSAssertDebug(self.interaction); switch (self.interaction.interactionType) { case OWSInteractionType_Unknown: OWSFailDebug(@"Unknown interaction type."); return nil; case OWSInteractionType_IncomingMessage: case OWSInteractionType_OutgoingMessage: return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSMessageCell cellReuseIdentifier] forIndexPath:indexPath]; case OWSInteractionType_Error: case OWSInteractionType_Info: case OWSInteractionType_Call: return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier] forIndexPath:indexPath]; case OWSInteractionType_TypingIndicator: return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier] forIndexPath:indexPath]; } } - (nullable TSAttachmentStream *)firstValidAlbumAttachment { OWSAssertDebug(self.mediaAlbumItems.count > 0); // For now, use first valid attachment. TSAttachmentStream *_Nullable attachmentStream = nil; for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { attachmentStream = mediaAlbumItem.attachmentStream; break; } } return attachmentStream; } #pragma mark - OWSAudioPlayerDelegate - (void)setAudioPlaybackState:(AudioPlaybackState)audioPlaybackState { _audioPlaybackState = audioPlaybackState; BOOL isPlaying = (audioPlaybackState == AudioPlaybackState_Playing); [self.lastAudioMessageView setIsPlaying:isPlaying]; } - (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration { OWSAssertIsOnMainThread(); self.audioProgressSeconds = progress; [self.lastAudioMessageView setProgress:progress / duration]; } - (void)showInvalidAudioFileAlert { OWSAssertIsOnMainThread(); [OWSAlerts showErrorAlertWithMessage:NSLocalizedString(@"INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE", @"Message for the alert indicating that an audio file is invalid.")]; } #pragma mark - Displayable Text // TODO: Now that we're caching the displayable text on the view items, // I don't think we need this cache any more. - (NSCache *)displayableTextCache { static NSCache *cache = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ cache = [NSCache new]; // Cache the results for up to 1,000 messages. cache.countLimit = 1000; }); return cache; } - (DisplayableText *)displayableBodyTextForText:(NSString *)text interactionId:(NSString *)interactionId { OWSAssertDebug(text); OWSAssertDebug(interactionId.length > 0); NSString *displayableTextCacheKey = [@"body-" stringByAppendingString:interactionId]; return [self displayableTextForCacheKey:displayableTextCacheKey textBlock:^{ return text; }]; } - (DisplayableText *)displayableBodyTextForOversizeTextAttachment:(TSAttachmentStream *)attachmentStream interactionId:(NSString *)interactionId { OWSAssertDebug(attachmentStream); OWSAssertDebug(interactionId.length > 0); NSString *displayableTextCacheKey = [@"oversize-body-" stringByAppendingString:interactionId]; return [self displayableTextForCacheKey:displayableTextCacheKey textBlock:^{ NSData *textData = [NSData dataWithContentsOfURL:attachmentStream.originalMediaURL]; NSString *text = [[NSString alloc] initWithData:textData encoding:NSUTF8StringEncoding]; return text; }]; } - (DisplayableText *)displayableQuotedTextForText:(NSString *)text interactionId:(NSString *)interactionId { OWSAssertDebug(text); OWSAssertDebug(interactionId.length > 0); NSString *displayableTextCacheKey = [@"quoted-" stringByAppendingString:interactionId]; return [self displayableTextForCacheKey:displayableTextCacheKey textBlock:^{ return text; }]; } - (DisplayableText *)displayableCaptionForText:(NSString *)text attachmentId:(NSString *)attachmentId { OWSAssertDebug(text); OWSAssertDebug(attachmentId.length > 0); NSString *displayableTextCacheKey = [@"attachment-caption-" stringByAppendingString:attachmentId]; return [self displayableTextForCacheKey:displayableTextCacheKey textBlock:^{ return text; }]; } - (DisplayableText *)displayableTextForCacheKey:(NSString *)displayableTextCacheKey textBlock:(NSString * (^_Nonnull)(void))textBlock { OWSAssertDebug(displayableTextCacheKey.length > 0); DisplayableText *_Nullable displayableText = [[self displayableTextCache] objectForKey:displayableTextCacheKey]; if (!displayableText) { NSString *text = textBlock(); displayableText = [DisplayableText displayableText:text]; [[self displayableTextCache] setObject:displayableText forKey:displayableTextCacheKey]; } return displayableText; } #pragma mark - View State - (void)ensureViewState:(YapDatabaseReadTransaction *)transaction { OWSAssertIsOnMainThread(); OWSAssertDebug(transaction); OWSAssertDebug(!self.hasViewState); switch (self.interaction.interactionType) { case OWSInteractionType_Unknown: case OWSInteractionType_Offer: case OWSInteractionType_TypingIndicator: return; case OWSInteractionType_Error: case OWSInteractionType_Info: case OWSInteractionType_Call: self.systemMessageText = [self systemMessageTextWithTransaction:transaction]; OWSAssertDebug(self.systemMessageText.length > 0); return; case OWSInteractionType_IncomingMessage: case OWSInteractionType_OutgoingMessage: break; default: OWSFailDebug(@"Unknown interaction type."); return; } OWSAssertDebug([self.interaction isKindOfClass:[TSOutgoingMessage class]] || [self.interaction isKindOfClass:[TSIncomingMessage class]]); self.hasViewState = YES; TSMessage *message = (TSMessage *)self.interaction; // Check for quoted replies _before_ media album handling, // since that logic may exit early. if (message.quotedMessage) { self.quotedReply = [OWSQuotedReplyModel quotedReplyWithQuotedMessage:message.quotedMessage threadId:message.uniqueThreadId transaction:transaction]; if (self.quotedReply.body.length > 0) { self.displayableQuotedText = [self displayableQuotedTextForText:self.quotedReply.body interactionId:message.uniqueId]; } } TSAttachment *_Nullable oversizeTextAttachment = [message oversizeTextAttachmentWithTransaction:transaction]; if ([oversizeTextAttachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *oversizeTextAttachmentStream = (TSAttachmentStream *)oversizeTextAttachment; self.displayableBodyText = [self displayableBodyTextForOversizeTextAttachment:oversizeTextAttachmentStream interactionId:message.uniqueId]; } else if ([oversizeTextAttachment isKindOfClass:[TSAttachmentPointer class]]) { TSAttachmentPointer *oversizeTextAttachmentPointer = (TSAttachmentPointer *)oversizeTextAttachment; // TODO: Handle backup restore. self.messageCellType = OWSMessageCellType_OversizeTextDownloading; self.attachmentPointer = (TSAttachmentPointer *)oversizeTextAttachmentPointer; return; } else { NSString *_Nullable bodyText = [message bodyTextWithTransaction:transaction]; if (bodyText) { self.displayableBodyText = [self displayableBodyTextForText:bodyText interactionId:message.uniqueId]; } } NSArray *mediaAttachments = [message mediaAttachmentsWithTransaction:transaction]; NSArray *mediaAlbumItems = [self albumItemsForMediaAttachments:mediaAttachments]; if (mediaAlbumItems.count > 0) { if (mediaAlbumItems.count == 1) { ConversationMediaAlbumItem *mediaAlbumItem = mediaAlbumItems.firstObject; if (mediaAlbumItem.attachmentStream && !mediaAlbumItem.attachmentStream.isValidVisualMedia) { OWSLogWarn(@"Treating invalid media as generic attachment."); self.messageCellType = OWSMessageCellType_GenericAttachment; return; } } self.mediaAlbumItems = mediaAlbumItems; self.messageCellType = OWSMessageCellType_MediaMessage; return; } // Only media galleries should have more than one attachment. OWSAssertDebug(mediaAttachments.count <= 1); TSAttachment *_Nullable mediaAttachment = mediaAttachments.firstObject; if (mediaAttachment) { if ([mediaAttachment isKindOfClass:[TSAttachmentStream class]]) { self.attachmentStream = (TSAttachmentStream *)mediaAttachment; if ([self.attachmentStream isAudio]) { CGFloat audioDurationSeconds = [self.attachmentStream audioDurationSeconds]; if (audioDurationSeconds > 0) { self.audioDurationSeconds = audioDurationSeconds; self.messageCellType = OWSMessageCellType_Audio; } else { self.messageCellType = OWSMessageCellType_GenericAttachment; } } else if (self.messageCellType == OWSMessageCellType_Unknown) { self.messageCellType = OWSMessageCellType_GenericAttachment; } } else if ([mediaAttachment isKindOfClass:[TSAttachmentPointer class]]) { if ([mediaAttachment isAudio]) { self.audioDurationSeconds = 0; self.messageCellType = OWSMessageCellType_Audio; } else { self.messageCellType = OWSMessageCellType_GenericAttachment; } self.attachmentPointer = (TSAttachmentPointer *)mediaAttachment; } else { OWSFailDebug(@"Unknown attachment type"); } } if (self.hasBodyText) { if (self.messageCellType == OWSMessageCellType_Unknown) { // OWSAssertDebug(message.attachmentIds.count == 0 // || (message.attachmentIds.count == 1 && // [message oversizeTextAttachmentWithTransaction:transaction] != nil)); self.messageCellType = OWSMessageCellType_TextOnlyMessage; } OWSAssertDebug(self.displayableBodyText); } if (self.hasBodyText && message.linkPreview) { self.linkPreview = message.linkPreview; if (message.linkPreview.imageAttachmentId && message.linkPreview.imageAttachmentId.length > 0) { TSAttachment *_Nullable linkPreviewAttachment = [TSAttachment fetchObjectWithUniqueID:message.linkPreview.imageAttachmentId transaction:transaction]; if (!linkPreviewAttachment) { OWSFailDebug(@"Could not load link preview image attachment."); } else if (!linkPreviewAttachment.isImage) { OWSFailDebug(@"Link preview attachment isn't an image."); } else if ([linkPreviewAttachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *attachmentStream = (TSAttachmentStream *)linkPreviewAttachment; if (!attachmentStream.isValidImage) { OWSFailDebug(@"Link preview image attachment isn't valid."); } else { self.linkPreviewAttachment = linkPreviewAttachment; } } else { self.linkPreviewAttachment = linkPreviewAttachment; } } } if (self.messageCellType == OWSMessageCellType_Unknown) { // Messages of unknown type (including messages with missing attachments) // are rendered like empty text messages, but without any interactivity. OWSLogWarn(@"Treating unknown message as empty text message: %@ %llu", message.class, message.timestamp); self.messageCellType = OWSMessageCellType_TextOnlyMessage; self.displayableBodyText = [[DisplayableText alloc] initWithFullText:@"" displayText:@"" isTextTruncated:NO]; } } - (NSArray *)albumItemsForMediaAttachments:(NSArray *)attachments { OWSAssertIsOnMainThread(); NSMutableArray *mediaAlbumItems = [NSMutableArray new]; for (TSAttachment *attachment in attachments) { if (!attachment.isVisualMedia) { // Well behaving clients should not send a mix of visual media (like JPG) and non-visual media (like PDF's) // Since we're not coped to handle a mix of media, return @[] OWSAssertDebug(mediaAlbumItems.count == 0); return @[]; } NSString *_Nullable caption = (attachment.caption ? [self displayableCaptionForText:attachment.caption attachmentId:attachment.uniqueId].displayText : nil); if (![attachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentPointer *attachmentPointer = (TSAttachmentPointer *)attachment; CGSize mediaSize = CGSizeZero; if (attachmentPointer.mediaSize.width > 0 && attachmentPointer.mediaSize.height > 0) { mediaSize = attachmentPointer.mediaSize; } [mediaAlbumItems addObject:[[ConversationMediaAlbumItem alloc] initWithAttachment:attachment attachmentStream:nil caption:caption mediaSize:mediaSize]]; continue; } TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; if (![attachmentStream isValidVisualMedia]) { OWSLogWarn(@"Filtering invalid media."); [mediaAlbumItems addObject:[[ConversationMediaAlbumItem alloc] initWithAttachment:attachment attachmentStream:nil caption:caption mediaSize:CGSizeZero]]; continue; } CGSize mediaSize = [attachmentStream imageSize]; if (mediaSize.width <= 0 || mediaSize.height <= 0) { OWSLogWarn(@"Filtering media with invalid size."); [mediaAlbumItems addObject:[[ConversationMediaAlbumItem alloc] initWithAttachment:attachment attachmentStream:nil caption:caption mediaSize:CGSizeZero]]; continue; } ConversationMediaAlbumItem *mediaAlbumItem = [[ConversationMediaAlbumItem alloc] initWithAttachment:attachment attachmentStream:attachmentStream caption:caption mediaSize:mediaSize]; [mediaAlbumItems addObject:mediaAlbumItem]; } return mediaAlbumItems; } - (NSString *)systemMessageTextWithTransaction:(YapDatabaseReadTransaction *)transaction { OWSAssertDebug(transaction); switch (self.interaction.interactionType) { case OWSInteractionType_Error: { TSErrorMessage *errorMessage = (TSErrorMessage *)self.interaction; return [errorMessage previewTextWithTransaction:transaction]; } case OWSInteractionType_Info: { TSInfoMessage *infoMessage = (TSInfoMessage *)self.interaction; return [infoMessage previewTextWithTransaction:transaction]; } default: OWSFailDebug(@"not a system message."); return nil; } } - (nullable NSString *)quotedAttachmentMimetype { return self.quotedReply.contentType; } - (nullable NSString *)quotedRecipientId { return self.quotedReply.authorId; } - (OWSMessageCellType)messageCellType { OWSAssertIsOnMainThread(); return _messageCellType; } - (nullable DisplayableText *)displayableBodyText { OWSAssertIsOnMainThread(); OWSAssertDebug(self.hasViewState); OWSAssertDebug(_displayableBodyText); OWSAssertDebug(_displayableBodyText.displayText); OWSAssertDebug(_displayableBodyText.fullText); return _displayableBodyText; } - (nullable TSAttachmentStream *)attachmentStream { OWSAssertIsOnMainThread(); OWSAssertDebug(self.hasViewState); return _attachmentStream; } - (nullable TSAttachmentPointer *)attachmentPointer { OWSAssertIsOnMainThread(); OWSAssertDebug(self.hasViewState); return _attachmentPointer; } - (nullable DisplayableText *)displayableQuotedText { OWSAssertIsOnMainThread(); OWSAssertDebug(self.hasViewState); OWSAssertDebug(_displayableQuotedText); OWSAssertDebug(_displayableQuotedText.displayText); OWSAssertDebug(_displayableQuotedText.fullText); return _displayableQuotedText; } - (void)copyTextAction { if (self.attachmentPointer != nil) { OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); return; } switch (self.messageCellType) { case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: case OWSMessageCellType_MediaMessage: case OWSMessageCellType_GenericAttachment: { OWSAssertDebug(self.displayableBodyText); [UIPasteboard.generalPasteboard setString:self.displayableBodyText.fullText]; break; } case OWSMessageCellType_Unknown: { OWSFailDebug(@"No text to copy"); break; } case OWSMessageCellType_OversizeTextDownloading: OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); return; } } - (void)copyMediaAction { if (self.attachmentPointer != nil) { OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); return; } switch (self.messageCellType) { case OWSMessageCellType_Unknown: case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: case OWSMessageCellType_GenericAttachment: { [self copyAttachmentToPasteboard:self.attachmentStream]; break; } case OWSMessageCellType_MediaMessage: { if (self.mediaAlbumItems.count == 1) { ConversationMediaAlbumItem *mediaAlbumItem = self.mediaAlbumItems.firstObject; if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { [self copyAttachmentToPasteboard:mediaAlbumItem.attachmentStream]; return; } } OWSFailDebug(@"Can't copy media album"); break; } case OWSMessageCellType_OversizeTextDownloading: OWSFailDebug(@"Can't copy not-yet-downloaded attachment"); return; } } - (void)copyAttachmentToPasteboard:(TSAttachmentStream *)attachment { OWSAssertDebug(attachment); NSString *utiType = [MIMETypeUtil utiTypeForMIMEType:attachment.contentType]; if (!utiType) { OWSFailDebug(@"Unknown MIME type: %@", attachment.contentType); utiType = (NSString *)kUTTypeGIF; } NSData *data = [NSData dataWithContentsOfURL:[attachment originalMediaURL]]; if (!data) { OWSFailDebug(@"Could not load attachment data"); return; } [UIPasteboard.generalPasteboard setData:data forPasteboardType:utiType]; } - (void)shareMediaAction { if (self.attachmentPointer != nil) { OWSFailDebug(@"Can't share not-yet-downloaded attachment"); return; } switch (self.messageCellType) { case OWSMessageCellType_Unknown: case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: case OWSMessageCellType_GenericAttachment: [AttachmentSharing showShareUIForAttachment:self.attachmentStream]; break; case OWSMessageCellType_MediaMessage: { // TODO: We need a "canShareMediaAction" method. OWSAssertDebug(self.mediaAlbumItems); NSMutableArray *attachmentStreams = [NSMutableArray new]; for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { [attachmentStreams addObject:mediaAlbumItem.attachmentStream]; } } if (attachmentStreams.count < 1) { OWSFailDebug(@"Can't share media album; no valid items."); return; } [AttachmentSharing showShareUIForAttachments:attachmentStreams completion:nil]; break; } case OWSMessageCellType_OversizeTextDownloading: OWSFailDebug(@"Can't share not-yet-downloaded attachment"); return; } } - (BOOL)canCopyMedia { if (self.attachmentPointer != nil) { // The attachment is still downloading. return NO; } switch (self.messageCellType) { case OWSMessageCellType_Unknown: case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: return NO; case OWSMessageCellType_GenericAttachment: case OWSMessageCellType_MediaMessage: { if (self.mediaAlbumItems.count == 1) { ConversationMediaAlbumItem *mediaAlbumItem = self.mediaAlbumItems.firstObject; if (mediaAlbumItem.attachmentStream && mediaAlbumItem.attachmentStream.isValidVisualMedia) { return YES; } } return NO; } case OWSMessageCellType_OversizeTextDownloading: return NO; } } - (BOOL)canSaveMedia { if (self.attachmentPointer != nil) { // The attachment is still downloading. return NO; } switch (self.messageCellType) { case OWSMessageCellType_Unknown: case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: return NO; case OWSMessageCellType_GenericAttachment: return NO; case OWSMessageCellType_MediaMessage: { for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { if (!mediaAlbumItem.attachmentStream) { continue; } if (!mediaAlbumItem.attachmentStream.isValidVisualMedia) { continue; } if (mediaAlbumItem.attachmentStream.isImage || mediaAlbumItem.attachmentStream.isAnimated) { return YES; } if (mediaAlbumItem.attachmentStream.isVideo) { if (UIVideoAtPathIsCompatibleWithSavedPhotosAlbum( mediaAlbumItem.attachmentStream.originalFilePath)) { return YES; } } } return NO; } case OWSMessageCellType_OversizeTextDownloading: return NO; } } - (void)saveMediaAction { if (self.attachmentPointer != nil) { OWSFailDebug(@"Can't save not-yet-downloaded attachment"); return; } switch (self.messageCellType) { case OWSMessageCellType_Unknown: case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: OWSFailDebug(@"Cannot save media data."); break; case OWSMessageCellType_GenericAttachment: OWSFailDebug(@"Cannot save media data."); break; case OWSMessageCellType_MediaMessage: { [self saveMediaAlbumItems]; break; } case OWSMessageCellType_OversizeTextDownloading: OWSFailDebug(@"Can't save not-yet-downloaded attachment"); return; } } - (void)saveMediaAlbumItems { // We need to do these writes serially to avoid "write busy" errors // from too many concurrent asset saves. [self saveMediaAlbumItems:[self.mediaAlbumItems mutableCopy]]; } - (void)saveMediaAlbumItems:(NSMutableArray *)mediaAlbumItems { if (mediaAlbumItems.count < 1) { return; } ConversationMediaAlbumItem *mediaAlbumItem = mediaAlbumItems.firstObject; [mediaAlbumItems removeObjectAtIndex:0]; if (!mediaAlbumItem.attachmentStream || !mediaAlbumItem.attachmentStream.isValidVisualMedia) { // Skip this item. } else if (mediaAlbumItem.attachmentStream.isImage || mediaAlbumItem.attachmentStream.isAnimated) { [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:mediaAlbumItem.attachmentStream.originalMediaURL]; } completionHandler:^(BOOL success, NSError *error) { if (error || !success) { OWSFailDebug(@"Image save failed: %@", error); } [self saveMediaAlbumItems:mediaAlbumItems]; }]; return; } else if (mediaAlbumItem.attachmentStream.isVideo) { [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:mediaAlbumItem.attachmentStream.originalMediaURL]; } completionHandler:^(BOOL success, NSError *error) { if (error || !success) { OWSFailDebug(@"Video save failed: %@", error); } [self saveMediaAlbumItems:mediaAlbumItems]; }]; return; } return [self saveMediaAlbumItems:mediaAlbumItems]; } - (void)deleteAction { [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [self.interaction removeWithTransaction:transaction]; if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage) { [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:self.interaction.timestamp using:transaction]; } }]; if (self.isGroupThread) { TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; // Only allow deletion on incoming and outgoing messages OWSInteractionType interationType = self.interaction.interactionType; if (interationType != OWSInteractionType_IncomingMessage && interationType != OWSInteractionType_OutgoingMessage) return; // Make sure it's an open group message TSMessage *message = (TSMessage *)self.interaction; if (!message.isOpenGroupMessage) return; // Get the open group SNOpenGroup *openGroup = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; if (openGroup == nil) return; // If it's an incoming message the user must have moderator status if (self.interaction.interactionType == OWSInteractionType_IncomingMessage) { NSString *userPublicKey = [LKStorage.shared getUserPublicKey]; if (![SNOpenGroupAPI isUserModerator:userPublicKey forChannel:openGroup.channel onServer:openGroup.server]) { return; } } // Delete the message BOOL wasSentByUser = (interationType == OWSInteractionType_OutgoingMessage); [[SNOpenGroupAPI deleteMessageWithID:message.openGroupServerMessageID forGroup:openGroup.channel onServer:openGroup.server isSentByUser:wasSentByUser].catch(^(NSError *error) { // Roll back [self.interaction save]; }) retainUntilComplete]; } } - (BOOL)hasBodyTextActionContent { return self.hasBodyText && self.displayableBodyText.fullText.length > 0; } - (BOOL)hasMediaActionContent { if (self.attachmentPointer != nil) { // The attachment is still downloading. return NO; } switch (self.messageCellType) { case OWSMessageCellType_Unknown: case OWSMessageCellType_TextOnlyMessage: case OWSMessageCellType_Audio: case OWSMessageCellType_GenericAttachment: return self.attachmentStream != nil; case OWSMessageCellType_MediaMessage: return self.firstValidAlbumAttachment != nil; case OWSMessageCellType_OversizeTextDownloading: return NO; } } - (BOOL)mediaAlbumHasFailedAttachment { OWSAssertDebug(self.messageCellType == OWSMessageCellType_MediaMessage); OWSAssertDebug(self.mediaAlbumItems.count > 0); for (ConversationMediaAlbumItem *mediaAlbumItem in self.mediaAlbumItems) { if (mediaAlbumItem.isFailedDownload) { return YES; } } return NO; } - (BOOL)userCanDeleteGroupMessage { if (!self.isGroupThread) return false; // Ensure the thread is a public chat and not an RSS feed TSGroupThread *groupThread = (TSGroupThread *)self.interaction.thread; // Only allow deletion on incoming and outgoing messages OWSInteractionType interationType = self.interaction.interactionType; if (interationType != OWSInteractionType_OutgoingMessage && interationType != OWSInteractionType_IncomingMessage) return false; // Make sure it's a public chat message TSMessage *message = (TSMessage *)self.interaction; if (!message.isOpenGroupMessage) return true; // Ensure we have the details needed to contact the server SNOpenGroup *publicChat = [LKStorage.shared getOpenGroupForThreadID:groupThread.uniqueId]; if (publicChat == nil) return true; if (interationType == OWSInteractionType_IncomingMessage) { // Only allow deletion on incoming messages if the user has moderation permission return [SNOpenGroupAPI isUserModerator:[SNGeneralUtilities getUserPublicKey] forChannel:publicChat.channel onServer:publicChat.server]; } else { return YES; } } @end NS_ASSUME_NONNULL_END