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

655 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:
// LinkPreviewView_SwiftUI(
// state: LinkPreview.SentState(
// linkPreview: linkPreview,
// imageAttachment: messageViewModel.linkPreviewAttachment
// ),
// isOutgoing: (messageViewModel.variant == .standardOutgoing),
// maxWidth: maxWidth,
// messageViewModel: messageViewModel,
// bodyLabelTextColor: nil,
// lastSearchText: nil
// )
//
// 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
)
}
}