Merge pull request #411 from mpretty-cyro/fix/quote-with-attachment

Added support for rendering quotes with attachments and bug fixes
pull/1061/head
Morgan Pretty 6 months ago committed by GitHub
commit 4dbe3b5c4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -934,25 +934,23 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold
// MARK: - Functions // MARK: - Functions
public func updateDraft(to draft: String) { public func updateDraft(to draft: String) {
let threadId: String = self.threadId /// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this,
let currentDraft: String = dependencies[singleton: .storage] /// worst case the `draft` just won't be saved)
.read { db in dependencies[singleton: .storage]
.readPublisher { [threadId] db in
try SessionThread try SessionThread
.select(.messageDraft) .select(.messageDraft)
.filter(id: threadId) .filter(id: threadId)
.asRequest(of: String.self) .asRequest(of: String.self)
.fetchOne(db) .fetchOne(db)
} }
.defaulting(to: "") .filter { existingDraft -> Bool in draft != existingDraft }
.flatMapStorageWritePublisher(using: dependencies) { [threadId] db, _ in
// 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 try SessionThread
.filter(id: threadId) .filter(id: threadId)
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) .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 /// 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

@ -470,7 +470,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
documentView = nil documentView = nil
bodyTappableLabel = 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 { guard !cellViewModel.variant.isDeletedMessage else {
let inset: CGFloat = 12 let inset: CGFloat = 12
let deletedMessageView: DeletedMessageView = DeletedMessageView( let deletedMessageView: DeletedMessageView = DeletedMessageView(
@ -484,19 +491,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
return return
} }
// If it's an incoming media message and the thread isn't trusted then show the placeholder view /// The `textOnlyMessage` variant has a slightly different behaviour (as it's the only variant which supports link previews)
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { /// so we handle that case first
let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor) // FIXME: We should support rendering link previews alongside the other variants (bigger refactor)
bubbleView.addSubview(mediaPlaceholderView) guard cellViewModel.cellType != .textOnlyMessage else {
mediaPlaceholderView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
return
}
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader, .unreadMarker: break
case .textOnlyMessage:
let inset: CGFloat = 12 let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
@ -582,12 +580,85 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true stackView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true
snContentView.addArrangedSubview(bubbleBackgroundView) snContentView.addArrangedSubview(bubbleBackgroundView)
} }
return
}
case .mediaMessage: func addViewWrappingInBubbleIfNeeded(_ targetView: UIView) {
// Body text view switch snContentView.arrangedSubviews.count {
if let body: String = cellViewModel.body, !body.isEmpty { 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 inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) 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( let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
for: cellViewModel, for: cellViewModel,
with: maxWidth, with: maxWidth,
@ -601,8 +672,42 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
bubbleView.addSubview(bodyTappableLabel) bubbleView.addSubview(bodyTappableLabel)
bodyTappableLabel.pin(to: bubbleView, withInset: inset) bodyTappableLabel.pin(to: bubbleView, withInset: inset)
snContentView.addArrangedSubview(bubbleBackgroundView) 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 // Album view
let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel) let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel)
let albumView = MediaAlbumView( let albumView = MediaAlbumView(
@ -637,52 +742,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
playbackRate: (playbackInfo?.playbackRate ?? 1), playbackRate: (playbackInfo?.playbackRate ?? 1),
oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1) oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1)
) )
bubbleView.addSubview(voiceMessageView)
voiceMessageView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
self.voiceMessageView = voiceMessageView self.voiceMessageView = voiceMessageView
addViewWrappingInBubbleIfNeeded(voiceMessageView)
case .audio, .genericAttachment: case .audio, .genericAttachment:
guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } 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 // Document view
let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor) let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor)
self.documentView = documentView self.documentView = documentView
stackView.addArrangedSubview(documentView) addViewWrappingInBubbleIfNeeded(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)
} }
} }

@ -27,10 +27,15 @@ struct MessageInfoScreen: View {
VStack( VStack(
alignment: .leading, alignment: .leading,
spacing: 10 spacing: 10
) {
VStack(
alignment: .leading,
spacing: 0
) { ) {
// Message bubble snapshot // Message bubble snapshot
MessageBubble( MessageBubble(
messageViewModel: messageViewModel, messageViewModel: messageViewModel,
attachmentOnly: false,
dependencies: dependencies dependencies: dependencies
) )
.clipShape( .clipShape(
@ -54,6 +59,7 @@ struct MessageInfoScreen: View {
.padding(.bottom, Values.verySmallSpacing) .padding(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing) .padding(.horizontal, Values.largeSpacing)
if isMessageFailed { if isMessageFailed {
let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo(
variant: messageViewModel.variant, variant: messageViewModel.variant,
@ -81,9 +87,9 @@ struct MessageInfoScreen: View {
.padding(.horizontal, Values.largeSpacing) .padding(.horizontal, Values.largeSpacing)
} }
if let attachments = messageViewModel.attachments, if let attachments = messageViewModel.attachments {
messageViewModel.cellType == .mediaMessage switch messageViewModel.cellType {
{ case .mediaMessage:
let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count]
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
@ -137,7 +143,39 @@ struct MessageInfoScreen: View {
} }
.padding(.vertical, Values.verySmallSpacing) .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)
}
}
}
// Attachment Info // Attachment Info
if let attachments = messageViewModel.attachments {
let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count]
ZStack { ZStack {
VStack( VStack(
alignment: .leading, alignment: .leading,
@ -394,6 +432,7 @@ struct MessageBubble: View {
static private let inset: CGFloat = 12 static private let inset: CGFloat = 12
let messageViewModel: MessageViewModel let messageViewModel: MessageViewModel
let attachmentOnly: Bool
let dependencies: Dependencies let dependencies: Dependencies
var bodyLabelTextColor: ThemeValue { var bodyLabelTextColor: ThemeValue {
@ -404,14 +443,14 @@ struct MessageBubble: View {
var body: some View { var body: some View {
ZStack { ZStack {
switch messageViewModel.cellType {
case .textOnlyMessage:
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset) let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset)
VStack( VStack(
alignment: .leading, alignment: .leading,
spacing: 0 spacing: 0
) { ) {
if !attachmentOnly {
// FIXME: We should support rendering link previews alongside quotes (bigger refactor)
if let linkPreview: LinkPreview = messageViewModel.linkPreview { if let linkPreview: LinkPreview = messageViewModel.linkPreview {
switch linkPreview.variant { switch linkPreview.variant {
case .standard: case .standard:
@ -455,7 +494,10 @@ struct MessageBubble: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.top, Self.inset) .padding(.top, Self.inset)
.padding(.horizontal, Self.inset) .padding(.horizontal, Self.inset)
.padding(.bottom, -Values.smallSpacing) .padding(.bottom, (messageViewModel.body?.isEmpty == false ?
-Values.smallSpacing :
Self.inset
))
} }
} }
@ -471,18 +513,8 @@ struct MessageBubble: View {
.padding(.all, Self.inset) .padding(.all, Self.inset)
} }
} }
case .mediaMessage: else {
if let bodyText: NSAttributedString = VisibleMessageCell.getBodyAttributedText( switch messageViewModel.cellType {
for: messageViewModel,
theme: ThemeManager.currentTheme,
primaryColor: ThemeManager.primaryColor,
textColor: bodyLabelTextColor,
searchText: nil,
using: dependencies
) {
AttributedText(bodyText)
.padding(.all, Self.inset)
}
case .voiceMessage: case .voiceMessage:
if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){
// TODO: Playback Info and check if playing function is needed // TODO: Playback Info and check if playing function is needed
@ -490,10 +522,6 @@ struct MessageBubble: View {
} }
case .audio, .genericAttachment: case .audio, .genericAttachment:
if let attachment: Attachment = messageViewModel.attachments?.first { if let attachment: Attachment = messageViewModel.attachments?.first {
VStack(
alignment: .leading,
spacing: Values.smallSpacing
) {
DocumentView_SwiftUI( DocumentView_SwiftUI(
maxWidth: $maxWidth, maxWidth: $maxWidth,
attachment: attachment, attachment: attachment,
@ -504,30 +532,10 @@ struct MessageBubble: View {
width: maxWidth, width: maxWidth,
alignment: .leading 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)
}
.modifier(MaxWidthEqualizer.notify)
.frame(
width: maxWidth,
alignment: .leading
)
} }
default: EmptyView()
} }
.modifier(MaxWidthEqualizer(width: $maxWidth))
} }
default: EmptyView()
} }
} }
} }

@ -349,11 +349,6 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
// (the album view) // (the album view)
guard self.attachments?.count == 1 else { return .mediaMessage } 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 // Pending audio attachments won't have a duration
if if
attachment.isAudio && ( attachment.isAudio && (

Loading…
Cancel
Save