This commit is contained in:
fmodf 2024-08-14 16:37:45 +02:00
parent 49dd4b158c
commit c7aaac02b8
5 changed files with 474 additions and 31 deletions

View file

@ -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";

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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<Bool>(
// 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
}
}