From c7aaac02b89f6851dfa134b312c6ddadb6f09584 Mon Sep 17 00:00:00 2001 From: fmodf Date: Wed, 14 Aug 2024 16:37:45 +0200 Subject: [PATCH] wip --- .../Resources/Strings/Localizable.strings | 3 +- .../ConversationMessageContainer.swift | 251 ++++++++++++++++++ .../Conversation/ConversationMessageRow.swift | 81 ++++++ .../Conversation/ConversationScreen.swift | 58 ++-- .../Conversation/ConversationTextInput.swift | 112 ++++++++ 5 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift create mode 100644 ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift create mode 100644 ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift diff --git a/ConversationsClassic/Resources/Strings/Localizable.strings b/ConversationsClassic/Resources/Strings/Localizable.strings index 555f755..8ae665d 100644 --- a/ConversationsClassic/Resources/Strings/Localizable.strings +++ b/ConversationsClassic/Resources/Strings/Localizable.strings @@ -48,6 +48,7 @@ // MARK: Conversation "Conversation.title" = "Conversation"; "Conversation.startError" = "Error occurs in conversation starting"; +"Chat.textfieldPrompt" = "Type a message"; @@ -57,8 +58,6 @@ -//"Chat.textfieldPrompt" = "Type a message"; - //"Chats.Create.Main.createGroup" = "Create public group"; //"Chats.Create.Main.createPrivateGroup" = "Create private group"; //"Chats.Create.Main.findGroup" = "Find public group"; diff --git a/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift b/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift new file mode 100644 index 0000000..0590243 --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift @@ -0,0 +1,251 @@ +import AVKit +import MapKit +import QuickLook +import SwiftUI + +struct ConversationMessageContainer: View { + let message: Message + let isOutgoing: Bool + + var body: some View { + if let msgText = message.body, msgText.isLocation { + EmbededMapView(location: msgText.getLatLon) + } else if let msgText = message.body, msgText.isContact { + ContactView(message: message) + // } else if message.attachmentType != nil { + // AttachmentView(message: message) + } else { + Text(message.body ?? "...") + .font(.body2) + .foregroundColor(.Material.Text.main) + .multilineTextAlignment(.leading) + .padding(10) + } + } +} + +struct MessageAttr: View { + let message: Message + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(message.date, style: .time) + .font(.sub2) + .foregroundColor(.Material.Shape.separator) + Spacer() + if message.status == .error { + Image(systemName: "exclamationmark.circle") + .font(.body3) + .foregroundColor(.Rainbow.red500) + } else if message.status == .pending { + Image(systemName: "clock") + .font(.body3) + .foregroundColor(.Material.Shape.separator) + } + } + } +} + +private struct EmbededMapView: View { + let location: CLLocationCoordinate2D + + var body: some View { + Map( + coordinateRegion: .constant(MKCoordinateRegion(center: location, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))), + interactionModes: [], + showsUserLocation: false, + userTrackingMode: .none, + annotationItems: [location], + annotationContent: { _ in + MapMarker(coordinate: location, tint: .blue) + } + ) + .frame(width: Const.mapPreviewSize, height: Const.mapPreviewSize) + .onTapGesture { + let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: location)) + mapItem.name = "Location" + mapItem.openInMaps(launchOptions: [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving]) + } + } +} + +private struct ContactView: View { + let message: Message + + var body: some View { + VStack { + ZStack { + Circle() + .frame(width: 44, height: 44) + .foregroundColor(contactName.firstLetterColor) + Text(contactName.firstLetter) + .foregroundColor(.white) + .font(.body1) + } + Text(message.body?.getContactJid ?? "...") + .font(.body2) + .foregroundColor(.Material.Text.main) + .multilineTextAlignment(.leading) + } + .padding() + .onTapGesture { + // TODO: Jump to add roster from here + } + } + + private var contactName: String { + message.body?.getContactJid ?? "?" + } +} + +// private struct AttachmentView: View { +// let message: Message +// +// var body: some View { +// if message.attachmentDownloadFailed || (message.attachmentLocalName != nil && message.sentError) { +// failed +// } else { +// switch message.attachmentType { +// case .image: +// if let thumbnail = thumbnail() { +// thumbnail +// .resizable() +// .aspectRatio(contentMode: .fit) +// .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) +// } else { +// placeholder +// } +// +// case .movie: +// if let file = message.attachmentLocalPath { +// VideoPlayerView(url: file) +// .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) +// } else { +// placeholder +// } +// +// case .file: +// if let file = message.attachmentLocalPath { +// DocumentPreview(url: file) +// .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) +// } else { +// placeholder +// } +// +// default: +// placeholder +// } +// } +// } +// +// @ViewBuilder private var placeholder: some View { +// Rectangle() +// .foregroundColor(.Material.Background.dark) +// .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) +// .overlay { +// ZStack { +// ProgressView() +// .scaleEffect(1.5) +// .progressViewStyle(CircularProgressViewStyle(tint: .Material.Elements.active)) +// let imageName = progressImageName(message.attachmentType ?? .file) +// Image(systemName: imageName) +// .font(.body1) +// .foregroundColor(.Material.Elements.active) +// } +// } +// } +// +// @ViewBuilder private var failed: some View { +// Rectangle() +// .foregroundColor(.Material.Background.dark) +// .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) +// .overlay { +// ZStack { +// VStack { +// Text(L10n.Attachment.Downloading.retry) +// .font(.body3) +// .foregroundColor(.Rainbow.red500) +// Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") +// .font(.body1) +// .foregroundColor(.Rainbow.red500) +// } +// } +// } +// .onTapGesture { +// if let url = message.attachmentRemotePath { +// store.dispatch(.fileAction(.downloadAttachmentFile(messageId: message.id, attachmentRemotePath: url))) +// } else if message.attachmentLocalName != nil && message.sentError { +// store.dispatch(.sharingAction(.retrySharing(messageId: message.id))) +// } +// } +// } +// +// private func progressImageName(_ type: MessageAttachmentType) -> String { +// switch type { +// case .image: +// return "photo" +// case .audio: +// return "music.note" +// case .movie: +// return "film" +// case .file: +// return "doc" +// } +// } +// +// private func thumbnail() -> Image? { +// guard let thumbnailPath = message.attachmentThumbnailPath else { return nil } +// guard let uiImage = UIImage(contentsOfFile: thumbnailPath.path()) else { return nil } +// return Image(uiImage: uiImage) +// } +// } + +// TODO: Make video player better! +private struct VideoPlayerView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context _: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = AVPlayer(url: url) + controller.allowsPictureInPicturePlayback = true + return controller + } + + func updateUIViewController(_: AVPlayerViewController, context _: Context) { + // Update the controller if needed. + } +} + +struct DocumentPreview: UIViewControllerRepresentable { + var url: URL + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_: QLPreviewController, context _: Context) { + // Update the controller if needed. + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, QLPreviewControllerDataSource { + var parent: DocumentPreview + + init(_ parent: DocumentPreview) { + self.parent = parent + } + + func numberOfPreviewItems(in _: QLPreviewController) -> Int { + 1 + } + + func previewController(_: QLPreviewController, previewItemAt _: Int) -> QLPreviewItem { + parent.url as QLPreviewItem + } + } +} diff --git a/ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift b/ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift new file mode 100644 index 0000000..1a5291b --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift @@ -0,0 +1,81 @@ +import Foundation +import SwiftUI + +struct ConversationMessageRow: View { + @EnvironmentObject var conversation: ConversationStore + let message: Message + @State private var offset: CGSize = .zero + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + if isOutgoing() { + Spacer() + MessageAttr(message: message) + .padding(.trailing, 4) + } + ConversationMessageContainer(message: message, isOutgoing: isOutgoing()) + .background(isOutgoing() ? Color.Material.Shape.alternate : Color.Material.Shape.white) + .clipShape(ConversationMessageBubble(isOutgoing: isOutgoing())) + if !isOutgoing() { + MessageAttr(message: message) + .padding(.leading, 4) + Spacer() + } + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background(Color.clearTappable) + .offset(offset) + .gesture( + DragGesture(minimumDistance: 30, coordinateSpace: .local) + .onChanged { value in + var width = value.translation.width + width = width > 0 ? 0 : width + offset = CGSize(width: width, height: 0) + } + .onEnded { value in + let targetWidth: CGFloat = -90 + withAnimation(.easeOut(duration: 0.1)) { + if value.translation.width <= targetWidth { + Vibration.success.vibrate() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + withAnimation(.easeOut(duration: 0.1)) { + offset = .zero + } + } + } else { + offset = .zero + } + } + if value.translation.width <= targetWidth { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + // store.dispatch(.conversationAction(.setReplyText(message.body ?? ""))) + } + } + } + ) + } + .listRowInsets(.zero) + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.Material.Background.light) + } + + private func isOutgoing() -> Bool { + message.from == conversation.roster.bareJid + } +} + +struct ConversationMessageBubble: Shape { + let isOutgoing: Bool + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: isOutgoing ? [.topLeft, .bottomLeft, .bottomRight] : [.topRight, .bottomLeft, .bottomRight], + cornerRadii: CGSize(width: 8, height: 10) + ) + return Path(path.cgPath) + } +} diff --git a/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift b/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift index a4eb1e3..c9f4ff2 100644 --- a/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift @@ -32,39 +32,38 @@ struct ConversationScreen: View { // Msg list let messages = conversation.messages if !messages.isEmpty { - ScrollViewReader { _ in + ScrollViewReader { proxy in List { - Text("Test") - // ForEach(messages) { message in - // ConversationMessageRow(message: message) - // .id(message.id) - // .onAppear { - // if message.id == messages.first?.id { - // firstIsVisible = true - // autoScroll = true - // } - // } - // .onDisappear { - // if message.id == messages.first?.id { - // firstIsVisible = false - // autoScroll = false - // } - // } - // } - // .rotationEffect(.degrees(180)) + ForEach(messages) { message in + ConversationMessageRow(message: message) + .id(message.id) + .onAppear { + if message.id == messages.first?.id { + firstIsVisible = true + autoScroll = true + } + } + .onDisappear { + if message.id == messages.first?.id { + firstIsVisible = false + autoScroll = false + } + } + } + .rotationEffect(.degrees(180)) } .rotationEffect(.degrees(180)) .listStyle(.plain) .background(Color.Material.Background.light) .scrollDismissesKeyboard(.immediately) .scrollIndicators(.hidden) - // .onChange(of: autoScroll) { new in - // if new, !firstIsVisible { - // withAnimation { - // proxy.scrollTo(messages.first?.id, anchor: .top) - // } - // } - // } + .onChange(of: autoScroll) { new in + if new, !firstIsVisible { + withAnimation { + proxy.scrollTo(messages.first?.id, anchor: .top) + } + } + } } } else { Spacer() @@ -98,9 +97,10 @@ struct ConversationScreen: View { } } } - // .safeAreaInset(edge: .bottom, spacing: 0) { - // ConversationTextInput(autoScroll: $autoScroll) - // } + .environmentObject(conversation) + .safeAreaInset(edge: .bottom, spacing: 0) { + ConversationTextInput(autoScroll: $autoScroll) + } } } diff --git a/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift b/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift new file mode 100644 index 0000000..b564184 --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift @@ -0,0 +1,112 @@ +import SwiftUI +import UIKit + +struct ConversationTextInput: View { + @State private var messageStr = "" + @FocusState private var isFocused: Bool + @Binding var autoScroll: Bool + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .foregroundColor(.Material.Shape.separator) + .frame(height: 0.5) + .padding(.bottom, 8) + if !replyText.isEmpty { + VStack(spacing: 0) { + HStack(alignment: .top) { + Text(replyText) + .font(.body3) + .foregroundColor(Color.Material.Text.main) + .multilineTextAlignment(.leading) + .lineLimit(3) + .padding(8) + Spacer() + Image(systemName: "xmark") + .font(.title2) + .foregroundColor(.Material.Elements.active) + .padding(.leading, 8) + .tappablePadding(.symmetric(8)) { + // store.dispatch(.conversationAction(.setReplyText(""))) + } + .padding(8) + } + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 4) + .foregroundColor(.Material.Background.light) + .shadow(radius: 0.5) + ) + .padding(.bottom, 8) + .padding(.horizontal, 8) + } + .padding(.horizontal, 8) + } + HStack { + Image(systemName: "paperclip") + .font(.title2) + .foregroundColor(.Material.Elements.active) + .padding(.leading, 8) + .tappablePadding(.symmetric(8)) { + // store.dispatch(.sharingAction(.showSharing(true))) + } + TextField("", text: $messageStr, prompt: Text(L10n.Chat.textfieldPrompt).foregroundColor(.Material.Shape.separator)) + .font(.body1) + .foregroundColor(Color.Material.Text.main) + .accentColor(.Material.Shape.black) + .focused($isFocused) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.Material.Shape.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.vertical, 4) + let img = messageStr.isEmpty ? "paperplane" : "paperplane.fill" + Image(systemName: img) + .font(.title2) + .foregroundColor(messageStr.isEmpty ? .Material.Elements.inactive : .Material.Elements.active) + .padding(.trailing, 8) + .tappablePadding(.symmetric(8)) { + if !messageStr.isEmpty { + // guard let acc = store.state.conversationsState.currentChat?.account else { return } + // guard let contact = store.state.conversationsState.currentChat?.participant else { return } + // store.dispatch(.conversationAction(.sendMessage( + // from: acc, + // to: contact, + // body: composedMessage + // ))) + // messageStr = "" + // // UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + // store.dispatch(.conversationAction(.setReplyText(""))) + // autoScroll = true + } + } + } + } + .padding(.bottom, 8) + .background(Color.Material.Background.dark) + // .onChange(of: store.state.conversationsState.replyText) { new in + // if !new.isEmpty { + // isFocused = true + // } + // } + // .fullScreenCover(isPresented: Binding( + // get: { store.state.sharingState.sharingShown }, + // set: { _ in } + // )) { + // AttachmentPickerScreen() + // } + } + + private var replyText: String { + "" + // store.state.conversationsState.replyText + } + + private var composedMessage: String { + var result = "" + if !replyText.isEmpty { + result += replyText + "\n\n" + } + result += messageStr + return result + } +}