Files

969 lines
49 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import SwiftUI
import SessionUIKit
import SessionNetworkingKit
import SessionUtilitiesKit
import SessionMessagingKit
import Lucide
struct MessageInfoScreen: View {
@EnvironmentObject var host: HostWrapper
@State var index = 1
@State var feedbackMessage: String? = nil
@State var isExpanded: Bool = false
static private let cornerRadius: CGFloat = 17
var actions: [ContextMenuVC.Action]
var messageViewModel: MessageViewModel
let threadCanWrite: Bool
let onStartThread: (@MainActor () -> Void)?
let dependencies: Dependencies
let isMessageFailed: Bool
let isCurrentUser: Bool
let profileInfo: ProfilePictureView.Info?
var proFeatures: [String] = []
var proCTAVariant: ProCTAModal.Variant = .generic(renew: false)
public init(
actions: [ContextMenuVC.Action],
messageViewModel: MessageViewModel,
threadCanWrite: Bool,
onStartThread: (@MainActor () -> Void)?,
using dependencies: Dependencies
) {
self.actions = actions
self.messageViewModel = messageViewModel
self.threadCanWrite = threadCanWrite
self.onStartThread = onStartThread
self.dependencies = dependencies
self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state)
self.isCurrentUser = (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId)
self.profileInfo = ProfilePictureView.Info.generateInfoFrom(
size: .message,
publicKey: (
// Prioritise the profile.id because we override it for
// messages sent by the current user in communities
messageViewModel.profile?.id ??
messageViewModel.authorId
),
threadVariant: .contact, // Always show the display picture in 'contact' mode
displayPictureUrl: nil,
profile: messageViewModel.profile,
profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none),
using: dependencies
).front
(self.proFeatures, self.proCTAVariant) = getProFeaturesInfo()
}
var body: some View {
ZStack (alignment: .topLeading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(
alignment: .leading,
spacing: 10
) {
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)
if isMessageFailed {
let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo(
variant: messageViewModel.variant,
hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient,
hasAttachments: (messageViewModel.attachments?.isEmpty == false)
)
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(.bottom, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
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)
}
}
.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
if let attachments = messageViewModel.attachments, !attachments.isEmpty {
let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count]
ZStack {
VStack(
alignment: .leading,
spacing: Values.mediumSpacing
) {
InfoBlock(title: "attachmentsFileId".localized()) {
Text(attachment.downloadUrl.map { Network.FileServer.fileId(for: URL(string: $0)?.strippingQueryAndFragment?.absoluteString) } ?? "")
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
HStack(
alignment: .center
) {
InfoBlock(title: "attachmentsFileType".localized()) {
Text(attachment.contentType)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
InfoBlock(title: "attachmentsFileSize".localized()) {
Text(Format.fileSize(attachment.byteCount))
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
}
HStack(
alignment: .center
) {
let resolution: String = {
guard let width = attachment.width, let height = attachment.height else { return "attachmentsNa".localized() }
return "\(width)×\(height)"
}()
InfoBlock(title: "attachmentsResolution".localized()) {
Text(resolution)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
let duration: String = {
guard let duration = attachment.duration else { return "attachmentsNa".localized() }
return floor(duration).formatted(format: .videoDuration)
}()
InfoBlock(title: "attachmentsDuration".localized()) {
Text(duration)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
Spacer()
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.all, Values.largeSpacing)
}
.frame(maxHeight: .infinity)
.backgroundColor(themeColor: .backgroundSecondary)
.cornerRadius(Self.cornerRadius)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
// Message Info
ZStack {
VStack(
alignment: .leading,
spacing: Values.mediumSpacing
) {
// Pro feature message
if proFeatures.count > 0 {
VStack(
alignment: .leading,
spacing: Values.mediumSpacing
) {
HStack(spacing: Values.verySmallSpacing) {
SessionProBadge_SwiftUI(size: .small)
Text("message".localized())
.font(.Body.extraLargeBold)
.foregroundColor(themeColor: .textPrimary)
}
.onTapGesture {
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
proCTAVariant,
onConfirm: {
dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded(
presenting: { bottomSheet in
self.host.controller?.present(bottomSheet, animated: true)
}
)
},
presenting: { modal in
self.host.controller?.present(modal, animated: true)
}
)
}
Text(
"proMessageInfoFeatures"
.put(key: "app_pro", value: Constants.app_pro)
.localized()
)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
VStack(
alignment: .leading,
spacing: Values.smallSpacing
) {
ForEach(self.proFeatures, id: \.self) { feature in
HStack(spacing: Values.smallSpacing) {
AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17))
.font(.system(size: 17))
.foregroundColor(themeColor: .primary)
Text(feature)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
}
}
}
}
if isMessageFailed {
let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized()
InfoBlock(title: "theError".localized() + ":") {
Text(failureText)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .danger)
}
} else {
InfoBlock(title: "sent".localized()) {
Text(messageViewModel.dateForUI.fromattedForMessageInfo)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
InfoBlock(title: "received".localized()) {
Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo)
.font(.Body.largeRegular)
.foregroundColor(themeColor: .textPrimary)
}
}
InfoBlock(title: "from".localized()) {
HStack(
spacing: 10
) {
let size: ProfilePictureView.Info.Size = .list
if let info: ProfilePictureView.Info = self.profileInfo {
ProfilePictureSwiftUI(
size: size,
info: info,
additionalInfo: nil,
dataManager: dependencies[singleton: .imageDataManager]
)
.frame(
width: size.viewSize,
height: size.viewSize,
alignment: .topLeading
)
}
VStack(
alignment: .leading,
spacing: Values.verySmallSpacing
) {
HStack(spacing: Values.verySmallSpacing) {
if isCurrentUser {
Text("you".localized())
.font(.Body.extraLargeBold)
.foregroundColor(themeColor: .textPrimary)
}
else if !messageViewModel.authorNameSuppressedId.isEmpty {
Text(messageViewModel.authorNameSuppressedId)
.font(.Body.extraLargeBold)
.foregroundColor(themeColor: .textPrimary)
}
if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) {
SessionProBadge_SwiftUI(size: .small)
.onTapGesture {
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
proCTAVariant,
onConfirm: {
dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded(
presenting: { bottomSheet in
self.host.controller?.present(bottomSheet, animated: true)
}
)
},
presenting: { modal in
self.host.controller?.present(modal, animated: true)
}
)
}
}
}
Text(messageViewModel.authorId)
.font(.Display.base)
.foregroundColor(
themeColor: {
if
messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) ||
messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue)
{
return .textSecondary
}
else {
return .textPrimary
}
}()
)
}
}
}
.onTapGesture {
showUserProfileModal()
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.all, Values.largeSpacing)
}
.frame(maxHeight: .infinity)
.backgroundColor(themeColor: .backgroundSecondary)
.cornerRadius(Self.cornerRadius)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
// Actions
if !actions.isEmpty {
ZStack {
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() {
switch (actions[index].shouldDismissInfoScreen, actions[index].feedback) {
case (false, _): break
case (true, .some):
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
dismiss()
})
default: dismiss()
}
}
feedbackMessage = actions[index].feedback
},
label: {
HStack(spacing: Values.largeSpacing) {
Image(uiImage: actions[index].icon!.withRenderingMode(.alwaysTemplate))
.resizable()
.scaledToFit()
.scaleEffect(x: (actions[index].flipIconForRTL ? -1 : 1), y: 1)
.foregroundColor(themeColor: tintColor)
.frame(width: 26, height: 26)
Text(actions[index].title)
.font(.Headings.H8)
.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)
.backgroundColor(themeColor: .backgroundSecondary)
.cornerRadius(Self.cornerRadius)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, Values.verySmallSpacing)
.padding(.horizontal, Values.largeSpacing)
}
}
}
}
.backgroundColor(themeColor: .backgroundPrimary)
.toastView(message: $feedbackMessage)
}
private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) {
var proFeatures: [String] = []
var proCTAVariant: ProCTAModal.Variant = .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired)
guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) }
if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) {
proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized())
}
if (
messageViewModel.isProMessage &&
messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit ||
dependencies[feature: .messageFeatureLongMessage]
) {
proFeatures.append("proIncreasedMessageLengthFeature".localized())
proCTAVariant = (
proFeatures.count > 1 ?
.generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) :
.longerMessages(renew: dependencies[singleton: .sessionProState].isSessionProExpired)
)
}
if (
ImageDataManager.isAnimatedImage(profileInfo?.source) ||
dependencies[feature: .messageFeatureAnimatedAvatar]
) {
proFeatures.append("proAnimatedDisplayPictureFeature".localized())
proCTAVariant = (
proFeatures.count > 1 ?
.generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) :
.animatedProfileImage(
isSessionProActivated: false,
renew: dependencies[singleton: .sessionProState].isSessionProExpired
)
)
}
return (proFeatures, proCTAVariant)
}
func showUserProfileModal() {
guard threadCanWrite else { return }
// FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding)
guard (try? SessionId.Prefix(from: messageViewModel.authorId)) != .blinded25 else { return }
guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.Info.generateInfoFrom(
size: .message,
publicKey: (
// Prioritise the profile.id because we override it for
// messages sent by the current user in communities
messageViewModel.profile?.id ??
messageViewModel.authorId
),
threadVariant: .contact, // Always show the display picture in 'contact' mode
displayPictureUrl: nil,
profile: messageViewModel.profile,
profileIcon: .none,
using: dependencies
).front else {
return
}
let (sessionId, blindedId): (String?, String?) = {
guard
(try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15,
let openGroupServer: String = messageViewModel.threadOpenGroupServer,
let openGroupPublicKey: String = messageViewModel.threadOpenGroupPublicKey
else {
return (messageViewModel.authorId, nil)
}
let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in
try BlindedIdLookup.fetchOrCreate(
db,
blindedId: messageViewModel.authorId,
openGroupServer: openGroupServer,
openGroupPublicKey: openGroupPublicKey,
isCheckingForOutbox: false,
using: dependencies
)
}
return (lookup?.sessionId, messageViewModel.authorId.truncated(prefix: 10, suffix: 10))
}()
let qrCodeImage: UIImage? = {
guard let sessionId: String = sessionId else { return nil }
return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore
}()
let isMessasgeRequestsEnabled: Bool = {
guard messageViewModel.threadVariant == .community else { return true }
return messageViewModel.profile?.blocksCommunityMessageRequests != true
}()
let (displayName, contactDisplayName): (String?, String?) = {
guard let sessionId: String = sessionId else {
return (messageViewModel.authorNameSuppressedId, nil)
}
let profile: Profile? = (
dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ??
dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) }
)
let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true)
guard !isCurrentUser else {
return ("you".localized(), "you".localized())
}
return (
(profile?.displayName(for: .contact) ?? messageViewModel.authorNameSuppressedId),
profile?.displayName(for: .contact, ignoringNickname: true)
)
}()
DispatchQueue.main.async {
let userProfileModal: ModalHostingViewController = ModalHostingViewController(
modal: UserProfileModal(
info: .init(
sessionId: sessionId,
blindedId: blindedId,
qrCodeImage: qrCodeImage,
profileInfo: profileInfo,
displayName: displayName,
contactDisplayName: contactDisplayName,
isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }),
isMessageRequestsEnabled: isMessasgeRequestsEnabled,
onStartThread: self.onStartThread,
onProBadgeTapped: {
dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded(
proCTAVariant,
onConfirm: {
dependencies[singleton: .sessionProState].showSessionProBottomSheetIfNeeded(
presenting: { bottomSheet in
self.host.controller?.present(bottomSheet, animated: true)
}
)
},
presenting: { modal in
self.host.controller?.present(modal, animated: true)
}
)
}
),
dataManager: dependencies[singleton: .imageDataManager]
)
)
self.host.controller?.present(userProfileModal, animated: true, completion: nil)
}
}
private func showMediaFullScreen(attachment: Attachment) {
if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController(
for: messageViewModel.threadId,
threadVariant: messageViewModel.threadVariant,
interactionId: messageViewModel.id,
selectedAttachmentId: attachment.id,
options: [ .sliderEnabled ],
useTransitioningDelegate: false,
using: dependencies
) {
self.host.controller?.present(mediaGalleryView, animated: true)
}
}
func dismiss() {
self.host.controller?.navigationController?.popViewController(animated: true)
}
}
// MARK: - MessageBubble
struct MessageBubble: View {
@State private var maxWidth: CGFloat?
@State private var isExpanded: Bool = false
static private let cornerRadius: CGFloat = 18
static private let inset: CGFloat = 12
let messageViewModel: MessageViewModel
let attachmentOnly: Bool
let dependencies: Dependencies
var bodyLabelTextColor: ThemeValue {
messageViewModel.variant == .standardOutgoing ?
.messageBubble_outgoingText :
.messageBubble_incomingText
}
var body: some View {
ZStack {
let maxWidth: CGFloat = (
VisibleMessageCell.getMaxWidth(
for: messageViewModel,
cellWidth: UIScreen.main.bounds.width
) - 2 * Self.inset
)
let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: messageViewModel)
let height: CGFloat = VisibleMessageCell.getBodyLabel(
for: messageViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: nil,
using: dependencies
).height
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(
viewModel: linkPreview.sentState(
imageAttachment: messageViewModel.linkPreviewAttachment,
using: dependencies
),
dataManager: dependencies[singleton: .imageDataManager],
isOutgoing: (messageViewModel.variant == .standardOutgoing),
maxWidth: maxWidth,
messageViewModel: messageViewModel,
bodyLabelTextColor: bodyLabelTextColor,
lastSearchText: nil
)
case .openGroupInvitation:
OpenGroupInvitationView_SwiftUI(
name: (linkPreview.title ?? ""),
url: linkPreview.url,
textColor: bodyLabelTextColor,
isOutgoing: (messageViewModel.variant == .standardOutgoing))
}
}
else {
if let quoteViewModel: QuoteViewModel = messageViewModel.quoteViewModel {
QuoteView_SwiftUI(
viewModel: quoteViewModel.with(
direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming),
currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []),
showProBadge: dependencies.mutate(cache: .libSession) {
$0.validateSessionProState(for: quoteViewModel.authorId)
},
thumbnailSource: .thumbnailFrom(
quoteViewModel: quoteViewModel,
using: dependencies
),
displayNameRetriever: Profile.defaultDisplayNameRetriever(
threadVariant: messageViewModel.threadVariant,
using: dependencies
)
),
dataManager: dependencies[singleton: .imageDataManager]
)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Self.inset)
.padding(.horizontal, Self.inset)
.padding(.bottom, (messageViewModel.body?.isEmpty == false ?
-Values.smallSpacing :
Self.inset
))
}
}
if let bodyText: ThemedAttributedString = VisibleMessageCell.getBodyAttributedText(
for: messageViewModel,
textColor: bodyLabelTextColor,
searchText: nil,
using: dependencies
) {
AttributedLabel(bodyText, maxWidth: maxWidth)
.padding(.horizontal, Self.inset)
.padding(.top, Self.inset)
.frame(
maxHeight: (isExpanded ? .infinity : maxHeight)
)
}
if (maxHeight < height && !isExpanded) {
Text("messageBubbleReadMore".localized())
.bold()
.font(.system(size: Values.smallFontSize))
.foregroundColor(themeColor: bodyLabelTextColor)
.padding(.horizontal, 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)
.padding(.top, Self.inset)
}
case .audio, .genericAttachment:
if let attachment: Attachment = messageViewModel.attachments?.first {
DocumentView_SwiftUI(
maxWidth: $maxWidth,
attachment: attachment,
textColor: bodyLabelTextColor
)
.modifier(MaxWidthEqualizer.notify)
.padding(.top, Self.inset)
.frame(
width: maxWidth,
alignment: .leading
)
}
default: EmptyView()
}
}
}
.padding(.bottom, Self.inset)
.onTapGesture {
self.isExpanded = true
}
}
}
}
// MARK: - InfoBlock
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)
.font(.Body.extraLargeBold)
.foregroundColor(themeColor: .textPrimary)
self.content()
}
.frame(
minWidth: minWidth,
alignment: .leading
)
}
}
// MARK: - MessageInfoViewController
final class MessageInfoViewController: SessionHostingViewController<MessageInfoScreen> {
init(
actions: [ContextMenuVC.Action],
messageViewModel: MessageViewModel,
threadCanWrite: Bool,
onStartThread: (() -> Void)?,
using dependencies: Dependencies
) {
let messageInfoView = MessageInfoScreen(
actions: actions,
messageViewModel: messageViewModel,
threadCanWrite: threadCanWrite,
onStartThread: onStartThread,
using: dependencies
)
super.init(rootView: messageInfoView)
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
let customTitleFontSize = Values.largeFontSize
setNavBarTitle("messageInfo".localized(), customFontSize: customTitleFontSize)
}
}
// MARK: - Preview
struct MessageInfoView_Previews: PreviewProvider {
static var messageViewModel: MessageViewModel {
let dependencies: Dependencies = .createEmpty()
let result = MessageViewModel(
optimisticMessageId: UUID(),
threadId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg",
threadVariant: .contact,
threadExpirationType: nil,
threadExpirationTimer: nil,
threadOpenGroupServer: nil,
threadOpenGroupPublicKey: nil,
threadContactNameInternal: "Test",
timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(),
receivedAtTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(),
authorId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg",
authorNameInternal: "Test",
body: "Mauris sapien dui, sagittis et fringilla eget, tincidunt vel mauris. Mauris bibendum quis ipsum ac pulvinar. Integer semper elit vitae placerat efficitur. Quisque blandit scelerisque orci, a fringilla dui. In a sollicitudin tortor. Vivamus consequat sollicitudin felis, nec pretium dolor bibendum sit amet. Integer non congue risus, id imperdiet diam. Proin elementum enim at felis commodo semper. Pellentesque magna magna, laoreet nec hendrerit in, suscipit sit amet risus. Nulla et imperdiet massa. Donec commodo felis quis arcu dignissim lobortis. Praesent nec fringilla felis, ut pharetra sapien. Donec ac dignissim nisi, non lobortis justo. Nulla congue velit nec sodales bibendum. Nullam feugiat, mauris ac consequat posuere, eros sem dignissim nulla, ac convallis dolor sem rhoncus dolor. Cras ut luctus risus, quis viverra mauris.",
expiresStartedAtMs: nil,
expiresInSeconds: nil,
isProMessage: true,
state: .failed,
isSenderModeratorOrAdmin: false,
currentUserProfile: Profile(
id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b",
name: "TestUser"
),
quoteViewModel: nil,
linkPreview: nil,
linkPreviewAttachment: nil,
attachments: nil
)
return result
}
static var actions: [ContextMenuVC.Action] {
return [
.reply(messageViewModel, nil),
.retry(messageViewModel, nil),
.delete(messageViewModel, nil)
]
}
static var previews: some View {
MessageInfoScreen(
actions: actions,
messageViewModel: messageViewModel,
threadCanWrite: true,
onStartThread: nil,
using: Dependencies.createEmpty()
)
}
}