diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 7915fe26b..f46abb965 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ D78E06302BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */; }; D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79622332B1DFE600037EACD /* DialerBottomSheet.swift */; }; D796F2002B0BB61A0041115F /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */; }; + D79F2D0A2C47F4BF0038FA07 /* TouchFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */; }; D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */; }; D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FBF2ACC2E390081A588 /* HistoryView.swift */; }; D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A03FC52ACC458A0081A588 /* SplashScreen.swift */; }; @@ -307,6 +308,7 @@ D78E062F2BEA6A4A00CE3783 /* ChangeLayoutBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLayoutBottomSheet.swift; sourceTree = ""; }; D79622332B1DFE600037EACD /* DialerBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialerBottomSheet.swift; sourceTree = ""; }; D796F1FF2B0BB61A0041115F /* ToastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = ""; }; + D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchFeedback.swift; sourceTree = ""; }; D7A03FBC2ACC2DB60081A588 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; D7A03FBF2ACC2E390081A588 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; D7A03FC52ACC458A0081A588 /* SplashScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashScreen.swift; sourceTree = ""; }; @@ -494,6 +496,7 @@ D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */, C67586AF2C09F247002E77BF /* URIHandler.swift */, C6A5A9462C10B64A0070FEA4 /* SingleSignOn */, + D79F2D092C47F4BF0038FA07 /* TouchFeedback.swift */, ); path = Utils; sourceTree = ""; @@ -1114,6 +1117,7 @@ C67586AE2C09F23C002E77BF /* URLExtension.swift in Sources */, 6613A0B02BAEB7F4008923A4 /* MeetingsListFragment.swift in Sources */, D76005F62B0798B00054B79A /* IntExtension.swift in Sources */, + D79F2D0A2C47F4BF0038FA07 /* TouchFeedback.swift in Sources */, D78E06282BE3811D00CE3783 /* CallStatsModel.swift in Sources */, D7E6D0512AEBDBD500A57AAF /* ContactsListBottomSheet.swift in Sources */, D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, diff --git a/Linphone/Assets.xcassets/forward.imageset/Contents.json b/Linphone/Assets.xcassets/forward.imageset/Contents.json new file mode 100644 index 000000000..9e6669929 --- /dev/null +++ b/Linphone/Assets.xcassets/forward.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "forward.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/forward.imageset/forward.svg b/Linphone/Assets.xcassets/forward.imageset/forward.svg new file mode 100644 index 000000000..f703e66a8 --- /dev/null +++ b/Linphone/Assets.xcassets/forward.imageset/forward.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg b/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg index 051b34cda..0365c1a4e 100644 --- a/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg +++ b/Linphone/Assets.xcassets/plus-circle.imageset/plus-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/reply.imageset/Contents.json b/Linphone/Assets.xcassets/reply.imageset/Contents.json new file mode 100644 index 000000000..f0dd5187e --- /dev/null +++ b/Linphone/Assets.xcassets/reply.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "reply.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/reply.imageset/reply.svg b/Linphone/Assets.xcassets/reply.imageset/reply.svg new file mode 100644 index 000000000..41b7e5a2c --- /dev/null +++ b/Linphone/Assets.xcassets/reply.imageset/reply.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d887ef0c0..9942036c4 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -132,6 +132,21 @@ }, "|" : { + }, + "❤️" : { + + }, + "👍" : { + + }, + "😂" : { + + }, + "😢" : { + + }, + "😮" : { + }, "0" : { @@ -1468,6 +1483,91 @@ } } }, + "menu_copy_chat_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier le texte" + } + } + } + }, + "menu_delete_selected_item" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + } + } + }, + "menu_forward_chat_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forward" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transférer" + } + } + } + }, + "menu_reply_to_chat_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reply" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Répondre" + } + } + } + }, + "menu_resend_chat_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-send" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ré-envoyer" + } + } + } + }, "Message" : { }, diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 38d796517..6f691cd00 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -28,10 +28,14 @@ struct ChatBubbleView: View { let geometryProxy: GeometryProxy + @State private var ticker = Ticker() + @State private var isPressed: Bool = false + @State private var timePassed: TimeInterval? + var body: some View { VStack { if !message.text.isEmpty || !message.attachments.isEmpty { - HStack { + HStack(alignment: .top, content: { if message.isOutgoing { Spacer() } @@ -44,19 +48,11 @@ struct ChatBubbleView: View { avatarSize: 35 ) .padding(.top, 30) - - Spacer() } } else if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && !message.isOutgoing { VStack { - Avatar( - contactAvatarModel: ContactAvatarModel(friend: nil, name: "??", address: "", withPresence: false), - avatarSize: 35 - ) - - Spacer() } - .hidden() + .padding(.leading, 43) } VStack(alignment: .leading, spacing: 0) { @@ -67,33 +63,6 @@ struct ChatBubbleView: View { .padding(.bottom, 2) } ZStack { - if conversationViewModel.displayedConversation != nil && conversationViewModel.displayedConversation!.isGroup && message.isFirstMessage { - VStack { - if message.isOutgoing { - Spacer() - } - - HStack { - if message.isOutgoing { - Spacer() - } - - VStack { - } - .frame(width: 15, height: 15) - .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 2)) - - if !message.isOutgoing { - Spacer() - } - } - - if !message.isOutgoing { - Spacer() - } - } - } HStack { if message.isOutgoing { @@ -137,7 +106,11 @@ struct ChatBubbleView: View { } .padding(.all, 15) .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .roundedCorner( + 16, + corners: message.isOutgoing && message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : + (!message.isOutgoing && message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) if !message.isOutgoing { Spacer() @@ -150,11 +123,33 @@ struct ChatBubbleView: View { if !message.isOutgoing { Spacer() } - } + }) .padding(.leading, message.isOutgoing ? 40 : 0) .padding(.trailing, !message.isOutgoing ? 40 : 0) } } + .onTapGesture {} + .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { (value) in + self.isPressed = value + if value == true { + self.timePassed = 0 + self.ticker.start(interval: 0.2) + } + + }, perform: {}) + .onReceive(ticker.objectWillChange) { (_) in + // Stop timer and reset the start date if the button in not pressed + guard self.isPressed else { + self.ticker.stop() + return + } + + self.timePassed = self.ticker.timeIntervalSinceStarted + withAnimation { + conversationViewModel.selectedMessage = message + } + + } } @ViewBuilder @@ -375,6 +370,49 @@ struct GifImageView: UIViewRepresentable { } } +class Ticker: ObservableObject { + + var startedAt: Date = Date() + + var timeIntervalSinceStarted: TimeInterval { + return Date().timeIntervalSince(startedAt) + } + + private var timer: Timer? + func start(interval: TimeInterval) { + stop() + startedAt = Date() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + self.objectWillChange.send() + } + } + + func stop() { + timer?.invalidate() + } + + deinit { + timer?.invalidate() + } + +} + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +extension View { + func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners) ) + } +} + /* #Preview { ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 13130fa90..e1ba2d369 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -19,6 +19,7 @@ import SwiftUI +// swiftlint:disable type_body_length struct ConversationFragment: View { @State private var orientation = UIDevice.current.orientation @@ -54,222 +55,130 @@ struct ConversationFragment: View { var body: some View { NavigationView { GeometryReader { geometry in - VStack(spacing: 1) { - if conversationViewModel.displayedConversation != nil { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - - HStack { - if (!(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)) || isShowConversationFragment { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 4) - .padding(.leading, -10) - .onTapGesture { - withAnimation { - if isShowConversationFragment { - isShowConversationFragment = false + ZStack { + VStack(spacing: 1) { + if conversationViewModel.displayedConversation != nil { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if (!(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)) || isShowConversationFragment { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + if isShowConversationFragment { + isShowConversationFragment = false + } + conversationViewModel.displayedConversation = nil } - conversationViewModel.displayedConversation = nil } - } - } - - Avatar(contactAvatarModel: conversationViewModel.displayedConversation!.avatarModel, avatarSize: 50) - .padding(.top, 4) - - Text(conversationViewModel.displayedConversation!.subject) - .default_text_style(styleSize: 16) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - .lineLimit(1) - - Spacer() - - Button { - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 4) - } - - Menu { - Button { - isMenuOpen = false - } label: { - HStack { - Text("See contact") - Spacer() - Image("user-circle") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } } + Avatar(contactAvatarModel: conversationViewModel.displayedConversation!.avatarModel, avatarSize: 50) + .padding(.top, 4) + + Text(conversationViewModel.displayedConversation!.subject) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .lineLimit(1) + + Spacer() + Button { - isMenuOpen = false } label: { - HStack { - Text("Copy SIP address") - Spacer() - Image("copy") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) } - Button(role: .destructive) { - isMenuOpen = false - } label: { - HStack { - Text("Delete history") - Spacer() - Image("trash-simple-red") - .resizable() - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } - } - } label: { - Image("dots-three-vertical") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 4) - } - .onTapGesture { - isMenuOpen = true - } - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) - .background(.white) - - if #available(iOS 16.0, *) { - ZStack(alignment: .bottomTrailing) { - UIList(viewModel: viewModel, - paginationState: paginationState, - conversationViewModel: conversationViewModel, - conversationsListViewModel: conversationsListViewModel, - isScrolledToBottom: $isScrolledToBottom, - showMessageMenuOnLongPress: showMessageMenuOnLongPress, - geometryProxy: geometry, - sections: conversationViewModel.conversationMessagesSection - ) - - if !isScrolledToBottom { + Menu { Button { - NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + isMenuOpen = false } label: { - ZStack { - - Image("caret-down") - .renderingMode(.template) - .foregroundStyle(.white) - .padding() - .background(Color.orangeMain500) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.2), radius: 4) - - if conversationViewModel.displayedConversationUnreadMessagesCount > 0 { - VStack { - HStack { - Spacer() - - HStack { - Text( - conversationViewModel.displayedConversationUnreadMessagesCount < 99 - ? String(conversationViewModel.displayedConversationUnreadMessagesCount) - : "99+" - ) - .foregroundStyle(.white) - .default_text_style(styleSize: 10) - .lineLimit(1) - - } - .frame(width: 18, height: 18) - .background(Color.redDanger500) - .cornerRadius(50) - } - - Spacer() - } - } + HStack { + Text("See contact") + Spacer() + Image("user-circle") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) } - } - .frame(width: 50, height: 50) - .padding() + + Button { + isMenuOpen = false + } label: { + HStack { + Text("Copy SIP address") + Spacer() + Image("copy") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } + + Button(role: .destructive) { + isMenuOpen = false + } label: { + HStack { + Text("Delete history") + Spacer() + Image("trash-simple-red") + .resizable() + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } + } label: { + Image("dots-three-vertical") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + } + .onTapGesture { + isMenuOpen = true } } - .onTapGesture { - UIApplication.shared.endEditing() - } - .onAppear { - conversationViewModel.getMessages() - } - .onDisappear { - conversationViewModel.resetMessage() - } - } else { - ScrollViewReader { proxy in + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + if #available(iOS 16.0, *) { ZStack(alignment: .bottomTrailing) { - List { - if conversationViewModel.conversationMessagesSection.first != nil { - let counter = conversationViewModel.conversationMessagesSection.first!.rows.count - ForEach(0.. conversationViewModel.conversationMessagesSection.first!.rows.count { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - conversationViewModel.getOldMessages() - } - } - - if index == 0 { - displayFloatingButton = false - } - } - .onDisappear { - if index == 0 { - displayFloatingButton = true - } - } - } - } - } - .scaleEffect(x: 1, y: -1, anchor: .center) - .listStyle(.plain) + UIList(viewModel: viewModel, + paginationState: paginationState, + conversationViewModel: conversationViewModel, + conversationsListViewModel: conversationsListViewModel, + isScrolledToBottom: $isScrolledToBottom, + showMessageMenuOnLongPress: showMessageMenuOnLongPress, + geometryProxy: geometry, + sections: conversationViewModel.conversationMessagesSection + ) - if displayFloatingButton { + if !isScrolledToBottom { Button { - if conversationViewModel.conversationMessagesSection.first != nil && conversationViewModel.conversationMessagesSection.first!.rows.first != nil { - withAnimation { - proxy.scrollTo(conversationViewModel.conversationMessagesSection.first!.rows.first!.id) - } - } + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) } label: { ZStack { @@ -321,216 +230,492 @@ struct ConversationFragment: View { .onDisappear { conversationViewModel.resetMessage() } - } - } - - if !conversationViewModel.mediasToSend.isEmpty || mediasIsLoading { - ZStack(alignment: .top) { - HStack { - if mediasIsLoading { - HStack { - Spacer() - - ProgressView() - - Spacer() - } - .frame(height: 120) - } - - if !mediasIsLoading { - LazyVGrid(columns: [ - GridItem(.adaptive(minimum: 100), spacing: 1) - ], spacing: 3) { - ForEach(conversationViewModel.mediasToSend, id: \.id) { attachment in - ZStack { - Rectangle() - .fill(Color(.white)) - .frame(width: 100, height: 100) - - AsyncImage(url: attachment.thumbnail) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) + } else { + ScrollViewReader { proxy in + ZStack(alignment: .bottomTrailing) { + List { + if conversationViewModel.conversationMessagesSection.first != nil { + let counter = conversationViewModel.conversationMessagesSection.first!.rows.count + ForEach(0.. conversationViewModel.conversationMessagesSection.first!.rows.count { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + conversationViewModel.getOldMessages() + } + } - if attachment.type == .video { - Image("play-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + if index == 0 { + displayFloatingButton = false } } - } placeholder: { - ProgressView() - } - .layoutPriority(-1) - .onTapGesture { - if conversationViewModel.mediasToSend.count == 1 { - withAnimation { - conversationViewModel.mediasToSend.removeAll() + .onDisappear { + if index == 0 { + displayFloatingButton = true } - } else { - guard let index = self.conversationViewModel.mediasToSend.firstIndex(of: attachment) else { return } - self.conversationViewModel.mediasToSend.remove(at: index) + } + } + } + } + .scaleEffect(x: 1, y: -1, anchor: .center) + .listStyle(.plain) + + if displayFloatingButton { + Button { + if conversationViewModel.conversationMessagesSection.first != nil && conversationViewModel.conversationMessagesSection.first!.rows.first != nil { + withAnimation { + proxy.scrollTo(conversationViewModel.conversationMessagesSection.first!.rows.first!.id) + } + } + } label: { + ZStack { + + Image("caret-down") + .renderingMode(.template) + .foregroundStyle(.white) + .padding() + .background(Color.orangeMain500) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + + if conversationViewModel.displayedConversationUnreadMessagesCount > 0 { + VStack { + HStack { + Spacer() + + HStack { + Text( + conversationViewModel.displayedConversationUnreadMessagesCount < 99 + ? String(conversationViewModel.displayedConversationUnreadMessagesCount) + : "99+" + ) + .foregroundStyle(.white) + .default_text_style(styleSize: 10) + .lineLimit(1) + + } + .frame(width: 18, height: 18) + .background(Color.redDanger500) + .cornerRadius(50) + } + + Spacer() } } } - .clipShape(RoundedRectangle(cornerRadius: 4)) - .contentShape(Rectangle()) + } - } - .frame( - width: geometry.size.width > 0 && CGFloat(102 * conversationViewModel.mediasToSend.count) > geometry.size.width - 20 - ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) - : CGFloat(102 * conversationViewModel.mediasToSend.count) - ) - } - } - .frame(maxWidth: .infinity) - .padding(.all, conversationViewModel.mediasToSend.isEmpty ? 0 : 10) - .background(Color.gray100) - - if !mediasIsLoading { - HStack { - Spacer() - - Button(action: { - withAnimation { - conversationViewModel.mediasToSend.removeAll() - } - }, label: { - Image("x") - .resizable() - .frame(width: 30, height: 30, alignment: .leading) - .padding(.all, 10) - }) - } - } - } - .transition(.move(edge: .bottom)) - } - - HStack(spacing: 0) { - Button { - } label: { - Image("smiley") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 28, height: 28, alignment: .leading) - .padding(.all, 6) - .padding(.top, 4) - } - .padding(.horizontal, isMessageTextFocused ? 0 : 2) - - Button { - self.isShowPhotoLibrary = true - self.mediasIsLoading = true - } label: { - Image("paperclip") - .renderingMode(.template) - .resizable() - .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) - .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading) - .padding(.all, isMessageTextFocused ? 0 : 6) - .padding(.top, 4) - .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) - } - .padding(.horizontal, isMessageTextFocused ? 0 : 2) - - Button { - self.isShowCamera = true - } label: { - Image("camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) - .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading) - .padding(.all, isMessageTextFocused ? 0 : 6) - .padding(.top, 4) - .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) - } - .padding(.horizontal, isMessageTextFocused ? 0 : 2) - - HStack { - if #available(iOS 16.0, *) { - TextField("Say something...", text: $conversationViewModel.messageText, axis: .vertical) - .default_text_style(styleSize: 15) - .focused($isMessageTextFocused) - .padding(.vertical, 5) - } else { - ZStack(alignment: .leading) { - TextEditor(text: $conversationViewModel.messageText) - .multilineTextAlignment(.leading) - .frame(maxHeight: 160) - .fixedSize(horizontal: false, vertical: true) - .default_text_style(styleSize: 15) - .focused($isMessageTextFocused) - - if conversationViewModel.messageText.isEmpty { - Text("Say something...") - .padding(.leading, 4) - .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0) - .foregroundStyle(Color.gray300) - .default_text_style(styleSize: 15) + .frame(width: 50, height: 50) + .padding() } } .onTapGesture { - isMessageTextFocused = true + UIApplication.shared.endEditing() } - } - - if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty { - Button { - } label: { - Image("microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 28, height: 28, alignment: .leading) - .padding(.all, 6) - .padding(.top, 4) + .onAppear { + conversationViewModel.getMessages() } - } else { - Button { - if conversationViewModel.displayedConversationHistorySize > 0 { - NotificationCenter.default.post(name: .onScrollToBottom, object: nil) - } - conversationViewModel.sendMessage() - } label: { - Image("paper-plane-tilt") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 28, height: 28, alignment: .leading) - .padding(.all, 6) - .padding(.top, 4) - .rotationEffect(.degrees(45)) + .onDisappear { + conversationViewModel.resetMessage() } - .padding(.trailing, 4) } } - .padding(.leading, 15) - .padding(.trailing, 5) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, minHeight: 55) - .background(.white) - .cornerRadius(30) - .overlay( - RoundedRectangle(cornerRadius: 30) - .inset(by: 0.5) - .stroke(Color.gray200, lineWidth: 1.5) - ) - .padding(.horizontal, 4) + + if !conversationViewModel.mediasToSend.isEmpty || mediasIsLoading { + ZStack(alignment: .top) { + HStack { + if mediasIsLoading { + HStack { + Spacer() + + ProgressView() + + Spacer() + } + .frame(height: 120) + } + + if !mediasIsLoading { + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 100), spacing: 1) + ], spacing: 3) { + ForEach(conversationViewModel.mediasToSend, id: \.id) { attachment in + ZStack { + Rectangle() + .fill(Color(.white)) + .frame(width: 100, height: 100) + + AsyncImage(url: attachment.thumbnail) { image in + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if attachment.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + } placeholder: { + ProgressView() + } + .layoutPriority(-1) + .onTapGesture { + if conversationViewModel.mediasToSend.count == 1 { + withAnimation { + conversationViewModel.mediasToSend.removeAll() + } + } else { + guard let index = self.conversationViewModel.mediasToSend.firstIndex(of: attachment) else { return } + self.conversationViewModel.mediasToSend.remove(at: index) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + } + } + .frame( + width: geometry.size.width > 0 && CGFloat(102 * conversationViewModel.mediasToSend.count) > geometry.size.width - 20 + ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) + : CGFloat(102 * conversationViewModel.mediasToSend.count) + ) + } + } + .frame(maxWidth: .infinity) + .padding(.all, conversationViewModel.mediasToSend.isEmpty ? 0 : 10) + .background(Color.gray100) + + if !mediasIsLoading { + HStack { + Spacer() + + Button(action: { + withAnimation { + conversationViewModel.mediasToSend.removeAll() + } + }, label: { + Image("x") + .resizable() + .frame(width: 30, height: 30, alignment: .leading) + .padding(.all, 10) + }) + } + } + } + .transition(.move(edge: .bottom)) + } + + HStack(spacing: 0) { + Button { + } label: { + Image("smiley") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 28, height: 28, alignment: .leading) + .padding(.all, 6) + .padding(.top, 4) + } + .padding(.horizontal, isMessageTextFocused ? 0 : 2) + + Button { + self.isShowPhotoLibrary = true + self.mediasIsLoading = true + } label: { + Image("paperclip") + .renderingMode(.template) + .resizable() + .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) + .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading) + .padding(.all, isMessageTextFocused ? 0 : 6) + .padding(.top, 4) + .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) + } + .padding(.horizontal, isMessageTextFocused ? 0 : 2) + + Button { + self.isShowCamera = true + } label: { + Image("camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) + .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading) + .padding(.all, isMessageTextFocused ? 0 : 6) + .padding(.top, 4) + .disabled(conversationViewModel.maxMediaCount <= conversationViewModel.mediasToSend.count || mediasIsLoading) + } + .padding(.horizontal, isMessageTextFocused ? 0 : 2) + + HStack { + if #available(iOS 16.0, *) { + TextField("Say something...", text: $conversationViewModel.messageText, axis: .vertical) + .default_text_style(styleSize: 15) + .focused($isMessageTextFocused) + .padding(.vertical, 5) + } else { + ZStack(alignment: .leading) { + TextEditor(text: $conversationViewModel.messageText) + .multilineTextAlignment(.leading) + .frame(maxHeight: 160) + .fixedSize(horizontal: false, vertical: true) + .default_text_style(styleSize: 15) + .focused($isMessageTextFocused) + + if conversationViewModel.messageText.isEmpty { + Text("Say something...") + .padding(.leading, 4) + .opacity(conversationViewModel.messageText.isEmpty ? 1 : 0) + .foregroundStyle(Color.gray300) + .default_text_style(styleSize: 15) + } + } + .onTapGesture { + isMessageTextFocused = true + } + } + + if conversationViewModel.messageText.isEmpty && conversationViewModel.mediasToSend.isEmpty { + Button { + } label: { + Image("microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 28, height: 28, alignment: .leading) + .padding(.all, 6) + .padding(.top, 4) + } + } else { + Button { + if conversationViewModel.displayedConversationHistorySize > 0 { + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + } + conversationViewModel.sendMessage() + } label: { + Image("paper-plane-tilt") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 28, height: 28, alignment: .leading) + .padding(.all, 6) + .padding(.top, 4) + .rotationEffect(.degrees(45)) + } + .padding(.trailing, 4) + } + } + .padding(.leading, 15) + .padding(.trailing, 5) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, minHeight: 55) + .background(.white) + .cornerRadius(30) + .overlay( + RoundedRectangle(cornerRadius: 30) + .inset(by: 0.5) + .stroke(Color.gray200, lineWidth: 1.5) + ) + .padding(.horizontal, 4) + } + .frame(maxWidth: .infinity, minHeight: 60) + .padding(.top, 12) + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? (isMessageTextFocused ? 12 : 0) : 12) + .padding(.horizontal, 10) + .background(Color.gray100) + } + } + .blur(radius: conversationViewModel.selectedMessage != nil ? 8 : 0) + + if conversationViewModel.selectedMessage != nil && conversationViewModel.displayedConversation != nil { + let iconSize = ((geometry.size.width - (conversationViewModel.displayedConversation!.isGroup ? 43 : 10) - 10) / 6) - 25 + VStack { + Spacer() + + VStack { + HStack { + if conversationViewModel.selectedMessage!.isOutgoing { + Spacer() + } + + HStack { + Button { + } label: { + Text("👍") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("❤️") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("😂") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("😮") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Text("😢") + .default_text_style(styleSize: iconSize > 50 ? 50 : iconSize) + } + .padding(.horizontal, 5) + + Button { + } label: { + Image("plus-circle") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: iconSize > 50 ? 50 : iconSize, height: iconSize > 50 ? 50 : iconSize, alignment: .leading) + } + .padding(.trailing, 5) + } + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background(.white) + .cornerRadius(20) + + if !conversationViewModel.selectedMessage!.isOutgoing { + Spacer() + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) + .shadow(color: .black.opacity(0.1), radius: 10) + + ChatBubbleView(conversationViewModel: conversationViewModel, message: conversationViewModel.selectedMessage!, geometryProxy: geometry) + .padding(.horizontal, 10) + .padding(.vertical, 1) + .shadow(color: .black.opacity(0.1), radius: 10) + + HStack { + if conversationViewModel.selectedMessage!.isOutgoing { + Spacer() + } + + VStack { + Button { + } label: { + HStack { + Text("menu_reply_to_chat_message") + .default_text_style(styleSize: 15) + Spacer() + Image("reply") + .resizable() + .frame(width: 20, height: 20, alignment: .leading) + } + .padding(.vertical, 5) + .padding(.horizontal, 20) + } + + Divider() + + Button { + } label: { + HStack { + Text("menu_copy_chat_message") + .default_text_style(styleSize: 15) + Spacer() + Image("copy") + .resizable() + .frame(width: 20, height: 20, alignment: .leading) + } + .padding(.vertical, 5) + .padding(.horizontal, 20) + } + + Divider() + + Button { + } label: { + HStack { + Text("menu_forward_chat_message") + .default_text_style(styleSize: 15) + Spacer() + Image("forward") + .resizable() + .frame(width: 20, height: 20, alignment: .leading) + } + .padding(.vertical, 5) + .padding(.horizontal, 20) + } + + Divider() + + Button { + } label: { + HStack { + Text("menu_delete_selected_item") + .foregroundStyle(.red) + .default_text_style(styleSize: 15) + Spacer() + Image("trash-simple-red") + .renderingMode(.template) + .resizable() + .foregroundStyle(.red) + .frame(width: 20, height: 20, alignment: .leading) + } + .padding(.vertical, 5) + .padding(.horizontal, 20) + } + } + .frame(maxWidth: geometry.size.width / 1.5) + .padding(.vertical, 8) + .background(.white) + .cornerRadius(20) + + if !conversationViewModel.selectedMessage!.isOutgoing { + Spacer() + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.leading, conversationViewModel.displayedConversation!.isGroup ? 43 : 0) + .shadow(color: .black.opacity(0.1), radius: 10) + } + + Spacer() + } + .frame(maxWidth: .infinity) + .background(.gray.opacity(0.1)) + .onTapGesture { + withAnimation { + conversationViewModel.selectedMessage = nil + } + } + .onAppear { + touchFeedback() + } + .onDisappear { + if conversationViewModel.selectedMessage != nil { + conversationViewModel.selectedMessage = nil + } } - .frame(maxWidth: .infinity, minHeight: 60) - .padding(.top, 12) - .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? (isMessageTextFocused ? 12 : 0) : 12) - .padding(.horizontal, 10) - .background(Color.gray100) } } .background(.white) @@ -569,6 +754,7 @@ struct ConversationFragment: View { .navigationViewStyle(.stack) } } +// swiftlint:enable type_body_length struct ScrollOffsetPreferenceKey: PreferenceKey { static var defaultValue: CGPoint = .zero diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift index 3fe7b7f84..c6aeabb78 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationsListFragment.swift @@ -138,11 +138,13 @@ struct ConversationsListFragment: View { if index < conversationsListViewModel.conversationsList.count { if conversationViewModel.displayedConversation != nil { conversationViewModel.displayedConversation = nil + conversationViewModel.selectedMessage = nil conversationViewModel.resetMessage() conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) conversationViewModel.getMessages() } else { + conversationViewModel.selectedMessage = nil withAnimation { conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index]) } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index e59068dc7..69a5baec6 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -42,6 +42,8 @@ class ConversationViewModel: ObservableObject { @Published var mediasToSend: [Attachment] = [] var maxMediaCount = 12 + @Published var selectedMessage: Message? + init() {} func addConversationDelegate() { diff --git a/Linphone/Utils/TouchFeedback.swift b/Linphone/Utils/TouchFeedback.swift new file mode 100644 index 000000000..846e1bc8f --- /dev/null +++ b/Linphone/Utils/TouchFeedback.swift @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import CoreHaptics +import AudioToolbox + +func touchFeedback() { + if CHHapticEngine.capabilitiesForHardware().supportsHaptics { + UIImpactFeedbackGenerator().impactOccurred() + } else { + AudioServicesPlaySystemSound(1519) // 1520 and 1521 are gradually stronger + } + }