// 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 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 if let body: String = messageViewModel.body, !body.isEmpty { let (bubbleBackgroundColor, bubbleTextColor): (ThemeValue, ThemeValue) = ( messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted ) ? (.messageBubble_incomingBackground, .messageBubble_incomingText) : (.messageBubble_outgoingBackground, .messageBubble_outgoingText) ZStack { RoundedRectangle(cornerRadius: 18) .fill(themeColor: bubbleBackgroundColor) Text(body) .foregroundColor(themeColor: bubbleTextColor) .padding( EdgeInsets( top: 8, leading: 16, bottom: 8, trailing: 16 ) ) } .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading ) .fixedSize(horizontal: true, vertical: true) .padding( EdgeInsets( top: 8, leading: 30, bottom: 4, trailing: 30 ) ) } 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: 11)) .foregroundColor(themeColor: tintColor) } } .padding( EdgeInsets( top: -8, leading: 30, bottom: 4, trailing: 30 ) ) } 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), shouldSupressControls: true, cornerRadius: 0 ) .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading ) .aspectRatio(1, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 15)) .padding( EdgeInsets( top: 0, leading: 30, bottom: 0, trailing: 30 ) ) } 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( EdgeInsets( top: 0, leading: 0, bottom: 8, trailing: 38 ) ) } .padding( EdgeInsets( top: 4, leading: 0, bottom: 4, trailing: 0 ) ) // Attachment Info ZStack { RoundedRectangle(cornerRadius: 17) .fill(themeColor: .backgroundSecondary) VStack( alignment: .leading, spacing: 16 ) { InfoBlock(title: "ATTACHMENT_INFO_FILE_ID".localized() + ":") { Text(attachment.serverId ?? "") .font(.system(size: 16)) .foregroundColor(themeColor: .textPrimary) } HStack( alignment: .center ) { InfoBlock(title: "ATTACHMENT_INFO_FILE_TYPE".localized() + ":") { Text(attachment.contentType) .font(.system(size: 16)) .foregroundColor(themeColor: .textPrimary) } Spacer() InfoBlock(title: "ATTACHMENT_INFO_FILE_SIZE".localized() + ":") { Text(Format.fileSize(attachment.byteCount)) .font(.system(size: 16)) .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: 16)) .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: 16)) .foregroundColor(themeColor: .textPrimary) } Spacer() } } .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading ) .padding( EdgeInsets( top: 24, leading: 24, bottom: 24, trailing: 24 ) ) } .frame(maxHeight: .infinity) .fixedSize(horizontal: false, vertical: true) .padding( EdgeInsets( top: 4, leading: 30, bottom: 4, trailing: 30 ) ) } // Message Info ZStack { RoundedRectangle(cornerRadius: 17) .fill(themeColor: .backgroundSecondary) VStack( alignment: .leading, spacing: 16 ) { InfoBlock(title: "MESSAGE_INFO_SENT".localized() + ":") { Text(messageViewModel.dateForUI.fromattedForMessageInfo) .font(.system(size: 16)) .foregroundColor(themeColor: .textPrimary) } InfoBlock(title: "MESSAGE_INFO_RECEIVED".localized() + ":") { Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) .font(.system(size: 16)) .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: 16)) .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: 4 ) { if !messageViewModel.authorName.isEmpty { Text(messageViewModel.authorName) .bold() .font(.system(size: 18)) .foregroundColor(themeColor: .textPrimary) } Text(messageViewModel.authorId) .font(.spaceMono(size: 16)) .foregroundColor(themeColor: .textPrimary) } } } } .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading ) .padding( EdgeInsets( top: 24, leading: 24, bottom: 24, trailing: 24 ) ) } .frame(maxHeight: .infinity) .fixedSize(horizontal: false, vertical: true) .padding( EdgeInsets( top: 4, leading: 30, bottom: 4, trailing: 30 ) ) // Actions if !actions.isEmpty { ZStack { RoundedRectangle(cornerRadius: 17) .fill(themeColor: .backgroundSecondary) VStack( alignment: .leading, spacing: 0 ) { ForEach( 0...(actions.count - 1), id: \.self ) { index in let tintColor: ThemeValue = actions[index].themeColor Button( action: { actions[index].work() dismiss?() }, label: { HStack(spacing: 24) { 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: 18)) .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( EdgeInsets( top: 0, leading: 24, bottom: 0, trailing: 24 ) ) } .frame(maxHeight: .infinity) .fixedSize(horizontal: false, vertical: true) .padding( EdgeInsets( top: 4, leading: 30, bottom: 4, trailing: 30 ) ) } } } } } } } struct InfoBlock: View where Content: View { let title: String let content: () -> Content var body: some View { VStack( alignment: .leading, spacing: 4 ) { Text(self.title) .bold() .font(.system(size: 18)) .foregroundColor(themeColor: .textPrimary) self.content() } .frame( minWidth: 100, alignment: .leading ) } } final class MessageInfoViewController: SessionHostingViewController { init(actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel) { let messageInfoView = MessageInfoView( actions: actions, messageViewModel: messageViewModel ) super.init(rootView: messageInfoView) rootView.content.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, threadExpirationType: nil, threadExpirationTimer: nil, 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 ) } }