diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 11f8c6e64..8021ad91c 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -934,25 +934,23 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - Functions public func updateDraft(to draft: String) { - let threadId: String = self.threadId - let currentDraft: String = dependencies[singleton: .storage] - .read { db in + /// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this, + /// worst case the `draft` just won't be saved) + dependencies[singleton: .storage] + .readPublisher { [threadId] db in try SessionThread .select(.messageDraft) .filter(id: threadId) .asRequest(of: String.self) .fetchOne(db) } - .defaulting(to: "") - - // Only write the updated draft to the database if it's changed (avoid unnecessary writes) - guard draft != currentDraft else { return } - - dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) - } + .filter { existingDraft -> Bool in draft != existingDraft } + .flatMapStorageWritePublisher(using: dependencies) { [threadId] db, _ in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + } + .sinkUntilComplete() } /// This method indicates whether the client should try to mark the thread or it's messages as read (it's an optimisation for fully read diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 7d03656d8..0391876fb 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -470,7 +470,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { documentView = nil bodyTappableLabel = nil - // Handle the deleted state first (it's much simpler than the others) + /// These variants have no content so do nothing after cleaning up old state + guard + cellViewModel.cellType != .typingIndicator && + cellViewModel.cellType != .dateHeader && + cellViewModel.cellType != .unreadMarker + else { return } + + /// Handle the deleted state first (it's much simpler than the others) guard !cellViewModel.variant.isDeletedMessage else { let inset: CGFloat = 12 let deletedMessageView: DeletedMessageView = DeletedMessageView( @@ -484,125 +491,223 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return } - // If it's an incoming media message and the thread isn't trusted then show the placeholder view - if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { - let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor) - bubbleView.addSubview(mediaPlaceholderView) - mediaPlaceholderView.pin(to: bubbleView) - snContentView.addArrangedSubview(bubbleBackgroundView) - return - } - - switch cellViewModel.cellType { - case .typingIndicator, .dateHeader, .unreadMarker: break + /// The `textOnlyMessage` variant has a slightly different behaviour (as it's the only variant which supports link previews) + /// so we handle that case first + // FIXME: We should support rendering link previews alongside the other variants (bigger refactor) + guard cellViewModel.cellType != .textOnlyMessage else { + let inset: CGFloat = 12 + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) - case .textOnlyMessage: - let inset: CGFloat = 12 - let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) - - if let linkPreview: LinkPreview = cellViewModel.linkPreview { - switch linkPreview.variant { - case .standard: - let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) - linkPreviewView.update( - with: LinkPreview.SentState( - linkPreview: linkPreview, - imageAttachment: cellViewModel.linkPreviewAttachment, - using: dependencies - ), - isOutgoing: cellViewModel.variant.isOutgoing, - delegate: self, - cellViewModel: cellViewModel, - bodyLabelTextColor: bodyLabelTextColor, - lastSearchText: lastSearchText, + if let linkPreview: LinkPreview = cellViewModel.linkPreview { + switch linkPreview.variant { + case .standard: + let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) + linkPreviewView.update( + with: LinkPreview.SentState( + linkPreview: linkPreview, + imageAttachment: cellViewModel.linkPreviewAttachment, using: dependencies - ) - self.linkPreviewView = linkPreviewView - bubbleView.addSubview(linkPreviewView) - linkPreviewView.pin(to: bubbleView, withInset: 0) - snContentView.addArrangedSubview(bubbleBackgroundView) - self.bodyTappableLabel = linkPreviewView.bodyTappableLabel - - case .openGroupInvitation: - let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( - name: (linkPreview.title ?? ""), - url: linkPreview.url, - textColor: bodyLabelTextColor, - isOutgoing: cellViewModel.variant.isOutgoing - ) - openGroupInvitationView.isAccessibilityElement = true - openGroupInvitationView.accessibilityIdentifier = "Community invitation" - openGroupInvitationView.accessibilityLabel = cellViewModel.linkPreview?.title - bubbleView.addSubview(openGroupInvitationView) - bubbleView.pin(to: openGroupInvitationView) - snContentView.addArrangedSubview(bubbleBackgroundView) - } - } - else { - // Stack view - let stackView = UIStackView(arrangedSubviews: []) - stackView.axis = .vertical - stackView.spacing = 2 - - // Quote view - if let quote: Quote = cellViewModel.quote { - let hInset: CGFloat = 2 - let quoteView: QuoteView = QuoteView( - for: .regular, - authorId: quote.authorId, - quotedText: quote.body, - threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, - direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + ), + isOutgoing: cellViewModel.variant.isOutgoing, + delegate: self, + cellViewModel: cellViewModel, + bodyLabelTextColor: bodyLabelTextColor, + lastSearchText: lastSearchText, using: dependencies ) - self.quoteView = quoteView - let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) - stackView.addArrangedSubview(quoteViewContainer) - } - - // Body text view - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( - for: cellViewModel, - with: maxWidth, - textColor: bodyLabelTextColor, - searchText: lastSearchText, - delegate: self, + self.linkPreviewView = linkPreviewView + bubbleView.addSubview(linkPreviewView) + linkPreviewView.pin(to: bubbleView, withInset: 0) + snContentView.addArrangedSubview(bubbleBackgroundView) + self.bodyTappableLabel = linkPreviewView.bodyTappableLabel + + case .openGroupInvitation: + let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( + name: (linkPreview.title ?? ""), + url: linkPreview.url, + textColor: bodyLabelTextColor, + isOutgoing: cellViewModel.variant.isOutgoing + ) + openGroupInvitationView.isAccessibilityElement = true + openGroupInvitationView.accessibilityIdentifier = "Community invitation" + openGroupInvitationView.accessibilityLabel = cellViewModel.linkPreview?.title + bubbleView.addSubview(openGroupInvitationView) + bubbleView.pin(to: openGroupInvitationView) + snContentView.addArrangedSubview(bubbleBackgroundView) + } + } + else { + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + + // Quote view + if let quote: Quote = cellViewModel.quote { + let hInset: CGFloat = 2 + let quoteView: QuoteView = QuoteView( + for: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: cellViewModel.threadVariant, + currentUserSessionId: cellViewModel.currentUserSessionId, + currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, + currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), + attachment: cellViewModel.quoteAttachment, using: dependencies ) - self.bodyTappableLabel = bodyTappableLabel - stackView.addArrangedSubview(bodyTappableLabel) - - // Constraints - bubbleView.addSubview(stackView) - stackView.pin(to: bubbleView, withInset: inset) - stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true - snContentView.addArrangedSubview(bubbleBackgroundView) + self.quoteView = quoteView + let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) + stackView.addArrangedSubview(quoteViewContainer) } - case .mediaMessage: // Body text view - if let body: String = cellViewModel.body, !body.isEmpty { - let inset: CGFloat = 12 - let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( - for: cellViewModel, - with: maxWidth, - textColor: bodyLabelTextColor, - searchText: lastSearchText, - delegate: self, - using: dependencies - ) - - self.bodyTappableLabel = bodyTappableLabel - bubbleView.addSubview(bodyTappableLabel) - bodyTappableLabel.pin(to: bubbleView, withInset: inset) + let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self, + using: dependencies + ) + self.bodyTappableLabel = bodyTappableLabel + stackView.addArrangedSubview(bodyTappableLabel) + + // Constraints + bubbleView.addSubview(stackView) + stackView.pin(to: bubbleView, withInset: inset) + stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true + snContentView.addArrangedSubview(bubbleBackgroundView) + } + return + } + + func addViewWrappingInBubbleIfNeeded(_ targetView: UIView) { + switch snContentView.arrangedSubviews.count { + case 0: + bubbleView.addSubview(targetView) + targetView.pin(to: bubbleView) snContentView.addArrangedSubview(bubbleBackgroundView) - } + + default: + /// Since we already have content we need to wrap the `targetView` in it's own + /// `bubbleView` (as it's likely the existing content is quote content) + let extraBubbleView: UIView = UIView() + extraBubbleView.clipsToBounds = true + extraBubbleView.themeBackgroundColor = (cellViewModel.variant.isIncoming ? + .messageBubble_incomingBackground : + .messageBubble_outgoingBackground + ) + extraBubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius + extraBubbleView.layer.maskedCorners = getCornerMask(from: .allCorners) + extraBubbleView.set(.width, greaterThanOrEqualTo: VisibleMessageCell.largeCornerRadius * 2) + + extraBubbleView.addSubview(targetView) + targetView.pin(to: extraBubbleView) + snContentView.addArrangedSubview(extraBubbleView) + } + } + + /// Add any quote & body if present + let inset: CGFloat = 12 + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) + + switch (cellViewModel.quote, cellViewModel.body) { + /// Both quote and body + case (.some(let quote), .some(let body)) where !body.isEmpty: + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + + // Quote view + let hInset: CGFloat = 2 + let quoteView: QuoteView = QuoteView( + for: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: cellViewModel.threadVariant, + currentUserSessionId: cellViewModel.currentUserSessionId, + currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, + currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), + attachment: cellViewModel.quoteAttachment, + using: dependencies + ) + self.quoteView = quoteView + let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset)) + stackView.addArrangedSubview(quoteViewContainer) + + // Body + let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self, + using: dependencies + ) + self.bodyTappableLabel = bodyTappableLabel + stackView.addArrangedSubview(bodyTappableLabel) + + // Constraints + bubbleView.addSubview(stackView) + stackView.pin(to: bubbleView, withInset: inset) + stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true + snContentView.addArrangedSubview(bubbleBackgroundView) + /// Just body + case (_, .some(let body)) where !body.isEmpty: + let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + for: cellViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: lastSearchText, + delegate: self, + using: dependencies + ) + + self.bodyTappableLabel = bodyTappableLabel + bubbleView.addSubview(bodyTappableLabel) + bodyTappableLabel.pin(to: bubbleView, withInset: inset) + snContentView.addArrangedSubview(bubbleBackgroundView) + + /// Just quote + case (.some(let quote), _): + let quoteView: QuoteView = QuoteView( + for: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: cellViewModel.threadVariant, + currentUserSessionId: cellViewModel.currentUserSessionId, + currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, + currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), + attachment: cellViewModel.quoteAttachment, + using: dependencies + ) + self.quoteView = quoteView + + bubbleView.addSubview(quoteView) + quoteView.pin(to: bubbleView, withInset: inset) + snContentView.addArrangedSubview(bubbleBackgroundView) + + /// Neither quote or body + default: break + } + + /// If it's an incoming media message and the thread isn't trusted then show the placeholder view + if cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor) + addViewWrappingInBubbleIfNeeded(mediaPlaceholderView) + return + } + + switch cellViewModel.cellType { + case .typingIndicator, .dateHeader, .unreadMarker, .textOnlyMessage: break + + case .mediaMessage: // Album view let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel) let albumView = MediaAlbumView( @@ -637,52 +742,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { playbackRate: (playbackInfo?.playbackRate ?? 1), oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1) ) - - bubbleView.addSubview(voiceMessageView) - voiceMessageView.pin(to: bubbleView) - snContentView.addArrangedSubview(bubbleBackgroundView) self.voiceMessageView = voiceMessageView + addViewWrappingInBubbleIfNeeded(voiceMessageView) case .audio, .genericAttachment: guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } - let inset: CGFloat = 12 - let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) - - // Stack view - let stackView = UIStackView(arrangedSubviews: []) - stackView.axis = .vertical - stackView.spacing = Values.smallSpacing - // Document view let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor) self.documentView = documentView - stackView.addArrangedSubview(documentView) - - // Body text view - if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point - let bodyContainerView: UIView = UIView() - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( - for: cellViewModel, - with: maxWidth, - textColor: bodyLabelTextColor, - searchText: lastSearchText, - delegate: self, - using: dependencies - ) - - self.bodyTappableLabel = bodyTappableLabel - bodyContainerView.addSubview(bodyTappableLabel) - bodyTappableLabel.pin(.top, to: .top, of: bodyContainerView) - bodyTappableLabel.pin(.leading, to: .leading, of: bodyContainerView, withInset: 12) - bodyTappableLabel.pin(.trailing, to: .trailing, of: bodyContainerView, withInset: -12) - bodyTappableLabel.pin(.bottom, to: .bottom, of: bodyContainerView, withInset: -12) - stackView.addArrangedSubview(bodyContainerView) - } - - bubbleView.addSubview(stackView) - stackView.pin(to: bubbleView) - snContentView.addArrangedSubview(bubbleBackgroundView) + addViewWrappingInBubbleIfNeeded(documentView) } } diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 4f9a09884..7f79d4459 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -28,116 +28,154 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: 10 ) { - // Message bubble snapshot - MessageBubble( - messageViewModel: messageViewModel, - dependencies: dependencies - ) - .clipShape( - RoundedRectangle(cornerRadius: Self.cornerRadius) - ) - .background( - RoundedRectangle(cornerRadius: Self.cornerRadius) - .fill( - themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? - .messageBubble_incomingBackground : - .messageBubble_outgoingBackground) - ) - ) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Values.smallSpacing) - .padding(.bottom, Values.verySmallSpacing) - .padding(.horizontal, Values.largeSpacing) - - if isMessageFailed { - let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( - variant: messageViewModel.variant, - hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient, - hasAttachments: (messageViewModel.attachments?.isEmpty == false) + VStack( + alignment: .leading, + spacing: 0 + ) { + // Message bubble snapshot + MessageBubble( + messageViewModel: messageViewModel, + attachmentOnly: false, + dependencies: dependencies + ) + .clipShape( + RoundedRectangle(cornerRadius: Self.cornerRadius) + ) + .background( + RoundedRectangle(cornerRadius: Self.cornerRadius) + .fill( + themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? + .messageBubble_incomingBackground : + .messageBubble_outgoingBackground) + ) ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Values.smallSpacing) + .padding(.bottom, Values.verySmallSpacing) + .padding(.horizontal, Values.largeSpacing) - HStack(spacing: 6) { - if let image: UIImage = image?.withRenderingMode(.alwaysTemplate) { - Image(uiImage: image) - .resizable() - .scaledToFit() - .foregroundColor(themeColor: tintColor) - .frame(width: 13, height: 12) - } + + if isMessageFailed { + let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( + variant: messageViewModel.variant, + hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient, + hasAttachments: (messageViewModel.attachments?.isEmpty == false) + ) - if let statusText: String = statusText { - Text(statusText) - .font(.system(size: Values.verySmallFontSize)) - .foregroundColor(themeColor: tintColor) + HStack(spacing: 6) { + if let image: UIImage = image?.withRenderingMode(.alwaysTemplate) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .foregroundColor(themeColor: tintColor) + .frame(width: 13, height: 12) + } + + if let statusText: String = statusText { + Text(statusText) + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: tintColor) + } } + .padding(.top, -Values.smallSpacing) + .padding(.bottom, Values.verySmallSpacing) + .padding(.horizontal, Values.largeSpacing) } - .padding(.top, -Values.smallSpacing) - .padding(.bottom, Values.verySmallSpacing) - .padding(.horizontal, Values.largeSpacing) - } - - if let attachments = messageViewModel.attachments, - messageViewModel.cellType == .mediaMessage - { - let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] - ZStack(alignment: .bottomTrailing) { - if attachments.count > 1 { - // Attachment carousel view - SessionCarouselView_SwiftUI( - index: $index, - isOutgoing: (messageViewModel.variant == .standardOutgoing), - contentInfos: attachments, - using: dependencies - ) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - } else { - MediaView_SwiftUI( - attachment: attachments[0], - isOutgoing: (messageViewModel.variant == .standardOutgoing), - shouldSupressControls: true, - cornerRadius: 0, - using: dependencies - ) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 15)) - .padding(.horizontal, Values.largeSpacing) - } - - if [ .downloaded, .uploaded ].contains(attachment.state) { - Button { - self.showMediaFullScreen(attachment: attachment) - } label: { - ZStack { - Circle() - .foregroundColor(.init(white: 0, opacity: 0.4)) - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 13)) - .foregroundColor(.white) + if let attachments = messageViewModel.attachments { + switch messageViewModel.cellType { + case .mediaMessage: + let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] + + ZStack(alignment: .bottomTrailing) { + if attachments.count > 1 { + // Attachment carousel view + SessionCarouselView_SwiftUI( + index: $index, + isOutgoing: (messageViewModel.variant == .standardOutgoing), + contentInfos: attachments, + using: dependencies + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + } else { + MediaView_SwiftUI( + attachment: attachments[0], + isOutgoing: (messageViewModel.variant == .standardOutgoing), + shouldSupressControls: true, + cornerRadius: 0, + using: dependencies + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .padding(.horizontal, Values.largeSpacing) + } + + if [ .downloaded, .uploaded ].contains(attachment.state) { + Button { + self.showMediaFullScreen(attachment: attachment) + } label: { + ZStack { + Circle() + .foregroundColor(.init(white: 0, opacity: 0.4)) + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 13)) + .foregroundColor(.white) + } + .frame(width: 26, height: 26) + } + .padding(.bottom, Values.smallSpacing) + .padding(.trailing, 38) + } } - .frame(width: 26, height: 26) - } - .padding(.bottom, Values.smallSpacing) - .padding(.trailing, 38) + .padding(.vertical, Values.verySmallSpacing) + + default: + MessageBubble( + messageViewModel: messageViewModel, + attachmentOnly: true, + dependencies: dependencies + ) + .clipShape( + RoundedRectangle(cornerRadius: Self.cornerRadius) + ) + .background( + RoundedRectangle(cornerRadius: Self.cornerRadius) + .fill( + themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? + .messageBubble_incomingBackground : + .messageBubble_outgoingBackground) + ) + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, Values.verySmallSpacing) + .padding(.horizontal, Values.largeSpacing) } } - .padding(.vertical, Values.verySmallSpacing) + } + + // Attachment Info + if let attachments = messageViewModel.attachments { + let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] - // Attachment Info ZStack { VStack( alignment: .leading, @@ -394,6 +432,7 @@ struct MessageBubble: View { static private let inset: CGFloat = 12 let messageViewModel: MessageViewModel + let attachmentOnly: Bool let dependencies: Dependencies var bodyLabelTextColor: ThemeValue { @@ -404,16 +443,16 @@ struct MessageBubble: View { var body: some View { ZStack { - switch messageViewModel.cellType { - case .textOnlyMessage: - let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset) - - VStack( - alignment: .leading, - spacing: 0 - ) { - if let linkPreview: LinkPreview = messageViewModel.linkPreview { - switch linkPreview.variant { + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset) + + VStack( + alignment: .leading, + spacing: 0 + ) { + if !attachmentOnly { + // FIXME: We should support rendering link previews alongside quotes (bigger refactor) + if let linkPreview: LinkPreview = messageViewModel.linkPreview { + switch linkPreview.variant { case .standard: LinkPreviewView_SwiftUI( state: LinkPreview.SentState( @@ -434,44 +473,34 @@ struct MessageBubble: View { url: linkPreview.url, textColor: bodyLabelTextColor, isOutgoing: (messageViewModel.variant == .standardOutgoing)) - } - } - else { - if let quote = messageViewModel.quote { - QuoteView_SwiftUI( - info: .init( - mode: .regular, - authorId: quote.authorId, - quotedText: quote.body, - threadVariant: messageViewModel.threadVariant, - currentUserSessionId: messageViewModel.currentUserSessionId, - currentUserBlinded15SessionId: messageViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: messageViewModel.currentUserBlinded25SessionId, - direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - attachment: messageViewModel.quoteAttachment - ), - using: dependencies - ) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Self.inset) - .padding(.horizontal, Self.inset) - .padding(.bottom, -Values.smallSpacing) - } } - - if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText( - for: messageViewModel, - theme: ThemeManager.currentTheme, - primaryColor: ThemeManager.primaryColor, - textColor: bodyLabelTextColor, - searchText: nil, - using: dependencies - ) { - AttributedText(bodyText) - .padding(.all, Self.inset) + } + else { + if let quote = messageViewModel.quote { + QuoteView_SwiftUI( + info: .init( + mode: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: messageViewModel.threadVariant, + currentUserSessionId: messageViewModel.currentUserSessionId, + currentUserBlinded15SessionId: messageViewModel.currentUserBlinded15SessionId, + currentUserBlinded25SessionId: messageViewModel.currentUserBlinded25SessionId, + direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), + attachment: messageViewModel.quoteAttachment + ), + using: dependencies + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Self.inset) + .padding(.horizontal, Self.inset) + .padding(.bottom, (messageViewModel.body?.isEmpty == false ? + -Values.smallSpacing : + Self.inset + )) } } - case .mediaMessage: + if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText( for: messageViewModel, theme: ThemeManager.currentTheme, @@ -483,51 +512,30 @@ struct MessageBubble: View { AttributedText(bodyText) .padding(.all, Self.inset) } - case .voiceMessage: - if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ - // TODO: Playback Info and check if playing function is needed - VoiceMessageView_SwiftUI(attachment: attachment) - } - case .audio, .genericAttachment: - if let attachment: Attachment = messageViewModel.attachments?.first { - VStack( - alignment: .leading, - spacing: Values.smallSpacing - ) { - DocumentView_SwiftUI( - maxWidth: $maxWidth, - attachment: attachment, - textColor: bodyLabelTextColor - ) - .modifier(MaxWidthEqualizer.notify) - .frame( - width: maxWidth, - alignment: .leading - ) - - if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText( - for: messageViewModel, - theme: ThemeManager.currentTheme, - primaryColor: ThemeManager.primaryColor, - textColor: bodyLabelTextColor, - searchText: nil, - using: dependencies - ) { - ZStack{ - AttributedText(bodyText) - .padding(.horizontal, Self.inset) - .padding(.bottom, Self.inset) - } + } + else { + switch messageViewModel.cellType { + case .voiceMessage: + if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ + // TODO: Playback Info and check if playing function is needed + VoiceMessageView_SwiftUI(attachment: attachment) + } + case .audio, .genericAttachment: + if let attachment: Attachment = messageViewModel.attachments?.first { + DocumentView_SwiftUI( + maxWidth: $maxWidth, + attachment: attachment, + textColor: bodyLabelTextColor + ) .modifier(MaxWidthEqualizer.notify) .frame( width: maxWidth, alignment: .leading ) } - } - .modifier(MaxWidthEqualizer(width: $maxWidth)) + default: EmptyView() } - default: EmptyView() + } } } } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 202c00a31..c28f7cb59 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -349,11 +349,6 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // (the album view) guard self.attachments?.count == 1 else { return .mediaMessage } - // Quote and LinkPreview overload the 'attachments' array and use it for their - // own purposes, otherwise check if the attachment is visual media - guard self.quote == nil else { return .textOnlyMessage } - guard self.linkPreview == nil else { return .textOnlyMessage } - // Pending audio attachments won't have a duration if attachment.isAudio && (