You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/Session/Media Viewing & Editing/MessageInfoView.swift

660 lines
34 KiB
Swift

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import SwiftUI
import SessionUIKit
import SessionSnodeKit
import SessionUtilitiesKit
import SessionMessagingKit
struct MessageInfoView: View {
@Environment(\.viewController) private var viewControllerHolder: UIViewController?
@State var index = 1
@State var showingAttachmentFullScreen = false
static private let cornerRadius: CGFloat = 17
var actions: [ContextMenuVC.Action]
var messageViewModel: MessageViewModel
var isMessageFailed: Bool {
return [.failed, .failedToSync].contains(messageViewModel.state)
}
var dismiss: (() -> Void)?
var body: some View {
NavigationView {
ZStack (alignment: .topLeading) {
if #available(iOS 14.0, *) {
ThemeManager.currentTheme.colorSwiftUI(for: .backgroundPrimary).ignoresSafeArea()
} else {
ThemeManager.currentTheme.colorSwiftUI(for: .backgroundPrimary)
}
ScrollView(.vertical, showsIndicators: false) {
VStack(
alignment: .leading,
spacing: 10
) {
// Message bubble snapshot
MessageBubble(
messageViewModel: messageViewModel
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.fixedSize(horizontal: true, 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,
hasAtLeastOneReadReceipt: messageViewModel.hasAtLeastOneReadReceipt
)
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)
}
if let attachments = messageViewModel.attachments {
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
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
} else {
MediaView_SwiftUI(
attachment: attachments[0],
isOutgoing: (messageViewModel.variant == .standardOutgoing),
cornerRadius: 0
)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal, Values.largeSpacing)
}
Button {
self.viewControllerHolder?.present(style: .fullScreen) {
MediaGalleryViewModel.createDetailViewSwiftUI(
for: messageViewModel.threadId,
threadVariant: messageViewModel.threadVariant,
interactionId: messageViewModel.id,
selectedAttachmentId: attachment.id,
options: [ .sliderEnabled ]
)
}
} 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)
}
.padding(.vertical, Values.verySmallSpacing)
// Attachment Info
ZStack {
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(themeColor: .backgroundSecondary)
VStack(
alignment: .leading,
spacing: Values.mediumSpacing
) {
InfoBlock(title: "ATTACHMENT_INFO_FILE_ID".localized() + ":") {
Text(attachment.serverId ?? "")
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
HStack(
alignment: .center
) {
InfoBlock(title: "ATTACHMENT_INFO_FILE_TYPE".localized() + ":") {
Text(attachment.contentType)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
InfoBlock(title: "ATTACHMENT_INFO_FILE_SIZE".localized() + ":") {
Text(Format.fileSize(attachment.byteCount))
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
}
HStack(
alignment: .center
) {
let resolution: String = {
guard let width = attachment.width, let height = attachment.height else { return "N/A" }
return "\(width)×\(height)"
}()
InfoBlock(title: "ATTACHMENT_INFO_RESOLUTION".localized() + ":") {
Text(resolution)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
let duration: String = {
guard let duration = attachment.duration else { return "N/A" }
return floor(duration).formatted(format: .videoDuration)
}()
InfoBlock(title: "ATTACHMENT_INFO_DURATION".localized() + ":") {
Text(duration)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.all, Values.largeSpacing)
}
.frame(maxHeight: .infinity)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
// Message Info
ZStack {
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(themeColor: .backgroundSecondary)
VStack(
alignment: .leading,
spacing: Values.mediumSpacing
) {
InfoBlock(title: "MESSAGE_INFO_SENT".localized() + ":") {
Text(messageViewModel.dateForUI.fromattedForMessageInfo)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
InfoBlock(title: "MESSAGE_INFO_RECEIVED".localized() + ":") {
Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
if isMessageFailed {
let failureText: String = messageViewModel.mostRecentFailureText ?? "Message failed to send"
InfoBlock(title: "ALERT_ERROR_TITLE".localized() + ":") {
Text(failureText)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .danger)
}
}
InfoBlock(title: "MESSAGE_INFO_FROM".localized() + ":") {
HStack(
spacing: 10
) {
let (info, additionalInfo) = ProfilePictureView.getProfilePictureInfo(
size: .message,
publicKey: messageViewModel.authorId,
threadVariant: .contact, // Always show the display picture in 'contact' mode
customImageData: nil,
profile: messageViewModel.profile,
profileIcon: (messageViewModel.isSenderOpenGroupModerator ? .crown : .none)
)
let size: ProfilePictureView.Size = .list
if let info: ProfilePictureView.Info = info {
ProfilePictureSwiftUI(
size: size,
info: info,
additionalInfo: additionalInfo
)
.frame(
width: size.viewSize,
height: size.viewSize,
alignment: .topLeading
)
}
VStack(
alignment: .leading,
spacing: Values.verySmallSpacing
) {
if !messageViewModel.authorName.isEmpty {
Text(messageViewModel.authorName)
.bold()
.font(.system(size: Values.mediumLargeFontSize))
.foregroundColor(themeColor: .textPrimary)
}
Text(messageViewModel.authorId)
.font(.spaceMono(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
}
}
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.all, Values.largeSpacing)
}
.frame(maxHeight: .infinity)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
// Actions
if !actions.isEmpty {
ZStack {
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(themeColor: .backgroundSecondary)
VStack(
alignment: .leading,
spacing: 0
) {
ForEach(
0...(actions.count - 1),
id: \.self
) { index in
let tintColor: ThemeValue = actions[index].isDestructive ? .danger : .textPrimary
Button(
action: {
actions[index].work()
dismiss?()
},
label: {
HStack(spacing: Values.largeSpacing) {
Image(uiImage: actions[index].icon!.withRenderingMode(.alwaysTemplate))
.resizable()
.scaledToFit()
.foregroundColor(themeColor: tintColor)
.frame(width: 26, height: 26)
Text(actions[index].title)
.bold()
.font(.system(size: Values.mediumLargeFontSize))
.foregroundColor(themeColor: tintColor)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
)
.frame(height: 60)
if index < (actions.count - 1) {
Divider()
.foregroundColor(themeColor: .borderSeparator)
}
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.horizontal, Values.largeSpacing)
}
.frame(maxHeight: .infinity)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
}
}
}
}
}
}
struct MessageBubble: View {
static private let cornerRadius: CGFloat = 18
let messageViewModel: MessageViewModel
var bubbleBackgroundColor: ThemeValue {
messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted ?
.messageBubble_incomingBackground :
.messageBubble_outgoingBackground
}
var body: some View {
ZStack {
switch messageViewModel.cellType {
case .typingIndicator, .dateHeader, .unreadMarker: break
case .textOnlyMessage:
let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * inset)
if let linkPreview: LinkPreview = messageViewModel.linkPreview {
switch linkPreview.variant {
case .standard:
let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth)
linkPreviewView.update(
with: LinkPreview.SentState(
linkPreview: linkPreview,
imageAttachment: messageViewModel.linkPreviewAttachment
),
isOutgoing: (messageViewModel.variant == .standardOutgoing),
delegate: self,
cellViewModel: messageViewModel,
bodyLabelTextColor: bodyLabelTextColor,
lastSearchText: lastSearchText
)
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 == .standardOutgoing)
)
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,
currentUserPublicKey: cellViewModel.currentUserPublicKey,
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
direction: (cellViewModel.variant == .standardOutgoing ?
.outgoing :
.incoming
),
attachment: cellViewModel.quoteAttachment,
hInset: hInset,
maxWidth: maxWidth
)
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.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)
}
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
)
self.bodyTappableLabel = bodyTappableLabel
bubbleView.addSubview(bodyTappableLabel)
bodyTappableLabel.pin(to: bubbleView, withInset: inset)
snContentView.addArrangedSubview(bubbleBackgroundView)
}
// Album view
let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel)
let albumView = MediaAlbumView(
mediaCache: mediaCache,
items: (cellViewModel.attachments?
.filter { $0.isVisualMedia })
.defaulting(to: []),
isOutgoing: (cellViewModel.variant == .standardOutgoing),
maxMessageWidth: maxMessageWidth
)
self.albumView = albumView
let size = getSize(for: cellViewModel)
albumView.set(.width, to: size.width)
albumView.set(.height, to: size.height)
albumView.loadMedia()
snContentView.addArrangedSubview(albumView)
unloadContent = { albumView.unloadMedia() }
case .audio:
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
return
}
let voiceMessageView: VoiceMessageView = VoiceMessageView()
voiceMessageView.update(
with: attachment,
isPlaying: (playbackInfo?.state == .playing),
progress: (playbackInfo?.progress ?? 0),
playbackRate: (playbackInfo?.playbackRate ?? 1),
oldPlaybackRate: (playbackInfo?.oldPlaybackRate ?? 1)
)
bubbleView.addSubview(voiceMessageView)
voiceMessageView.pin(to: bubbleView)
snContentView.addArrangedSubview(bubbleBackgroundView)
self.voiceMessageView = voiceMessageView
case .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)
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
)
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)
}
}
.background(
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(themeColor: bubbleBackgroundColor)
)
}
}
struct InfoBlock<Content>: View where Content: View {
let title: String
let content: () -> Content
private let minWidth: CGFloat = 100
var body: some View {
VStack(
alignment: .leading,
spacing: Values.verySmallSpacing
) {
Text(self.title)
.bold()
.font(.system(size: Values.mediumLargeFontSize))
.foregroundColor(themeColor: .textPrimary)
self.content()
}
.frame(
minWidth: minWidth,
alignment: .leading
)
}
}
final class MessageInfoViewController: SessionHostingViewController<MessageInfoView> {
init(actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel) {
let messageInfoView = MessageInfoView(
actions: actions,
messageViewModel: messageViewModel
)
super.init(rootView: messageInfoView)
rootView.dismiss = dismiss
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
let customTitleFontSize = Values.largeFontSize
setNavBarTitle("message_info_title".localized(), customFontSize: customTitleFontSize)
}
func dismiss() {
self.navigationController?.popViewController(animated: true)
}
}
struct MessageInfoView_Previews: PreviewProvider {
static var messageViewModel: MessageViewModel {
let result = MessageViewModel(
optimisticMessageId: UUID(),
threadId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg",
threadVariant: .contact,
threadHasDisappearingMessagesEnabled: false,
threadOpenGroupServer: nil,
threadOpenGroupPublicKey: nil,
threadContactNameInternal: "Test",
timestampMs: SnodeAPI.currentOffsetTimestampMs(),
receivedAtTimestampMs: SnodeAPI.currentOffsetTimestampMs(),
authorId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg",
authorNameInternal: "Test",
body: "Test Message",
expiresStartedAtMs: nil,
expiresInSeconds: nil,
state: .failed,
isSenderOpenGroupModerator: false,
currentUserProfile: Profile.fetchOrCreateCurrentUser(),
quote: nil,
quoteAttachment: nil,
linkPreview: nil,
linkPreviewAttachment: nil,
attachments: nil
)
return result
}
static var actions: [ContextMenuVC.Action] {
return [
.reply(messageViewModel, nil, using: Dependencies()),
.retry(messageViewModel, nil, using: Dependencies()),
.delete(messageViewModel, nil, using: Dependencies())
]
}
static var previews: some View {
MessageInfoView(
actions: actions,
messageViewModel: messageViewModel
)
}
}