diff --git a/client-ios/Vapp/Auth/LogInView.swift b/client-ios/Vapp/Auth/LogInView.swift index ee5599e..303459a 100644 --- a/client-ios/Vapp/Auth/LogInView.swift +++ b/client-ios/Vapp/Auth/LogInView.swift @@ -9,7 +9,6 @@ import SwiftUI struct LogInView: View { @StateObject var viewModel = LogInViewModel() - @StateObject var clientManager = ClientManager.shared var body: some View { NavigationStack { @@ -43,7 +42,7 @@ struct LogInView: View { .alert(isPresented: $viewModel.errorContainer.hasError) { Alert(title: Text("Error"), message: Text(viewModel.errorContainer.text)) } - .navigationDestination(isPresented: $clientManager.isAuthed) { + .navigationDestination(isPresented: $viewModel.showHomeView) { HomeView() } .navigationDestination(isPresented: $viewModel.showSignUp) { @@ -59,20 +58,30 @@ final class LogInViewModel: ObservableObject { @Published var isLoading = false @Published var showSignUp = false + @Published var showHomeView = false @Published var errorContainer = (hasError: false, text: "") + private let clientManager = ClientManager.shared + @MainActor func logIn() async { do { - try await ClientManager.shared.auth(username: username, password: password, path: Auth.loginPath) + try await clientManager.auth(username: username, password: password, path: Auth.loginPath) username = "" password = "" + if clientManager.isAuthed { + showHomeView = true + } } catch { errorContainer = (true, error.localizedDescription) } } + @MainActor func attemptStoredLogIn() async { - await ClientManager.shared.attemptStoredLogIn() + await clientManager.attemptStoredLogIn() + if clientManager.isAuthed { + showHomeView = true + } } } diff --git a/client-ios/Vapp/Auth/SignUpView.swift b/client-ios/Vapp/Auth/SignUpView.swift index e355f48..d882145 100644 --- a/client-ios/Vapp/Auth/SignUpView.swift +++ b/client-ios/Vapp/Auth/SignUpView.swift @@ -9,7 +9,6 @@ import SwiftUI struct SignUpView: View { @StateObject var viewModel = SignUpViewModel() - @StateObject var clientManager = ClientManager.shared var body: some View { NavigationStack { @@ -35,7 +34,7 @@ struct SignUpView: View { .alert(isPresented: $viewModel.errorContainer.hasError) { Alert(title: Text("Error"), message: Text(viewModel.errorContainer.text)) } - .navigationDestination(isPresented: $clientManager.isAuthed) { + .navigationDestination(isPresented: $viewModel.showHomeView) { HomeView() } } @@ -48,15 +47,21 @@ final class SignUpViewModel: ObservableObject { @Published var password = "" @Published var isLoading = false + @Published var showHomeView = false @Published var errorContainer = (hasError: false, text: "") + private let clientManager = ClientManager.shared + @MainActor func signUp() async { do { - try await ClientManager.shared.auth(username: username, password: password, displayName: displayName, path: Auth.signupPath) + try await clientManager.auth(username: username, password: password, displayName: displayName, path: Auth.signupPath) username = "" password = "" displayName = "" + if clientManager.isAuthed { + showHomeView = true + } } catch { errorContainer = (true, error.localizedDescription) } diff --git a/client-ios/Vapp/CallView.swift b/client-ios/Vapp/CallView.swift index b454d99..4472c55 100644 --- a/client-ios/Vapp/CallView.swift +++ b/client-ios/Vapp/CallView.swift @@ -13,7 +13,6 @@ struct CallView: View { @StateObject var viewModel: CallViewModel var body: some View { - NavigationStack { VStack { Text(viewModel.callStatus) diff --git a/client-ios/Vapp/ChatView.swift b/client-ios/Vapp/ChatView.swift index f2ef410..b538d06 100644 --- a/client-ios/Vapp/ChatView.swift +++ b/client-ios/Vapp/ChatView.swift @@ -7,12 +7,14 @@ import Combine import SwiftUI +import PhotosUI import VonageClientSDK struct ChatView: View { @StateObject var viewModel: ChatViewModel @State private var message: String = "" @State private var listTopId: Int? + @State private var selectedPhoto: PhotosPickerItem? var body: some View { NavigationStack { @@ -29,10 +31,23 @@ struct ChatView: View { let displayText = viewModel.generateDisplayText(event) Text(displayText.body) .frame(maxWidth: .infinity, alignment: .center) - case.messageText: + case .messageText: let displayText = viewModel.generateDisplayText(event) Text(displayText.body) .frame(maxWidth: .infinity, alignment: displayText.isUser ? .trailing : .leading) + case .messageImage: + let urlString = viewModel.generateDisplayText(event) + AsyncImage(url: URL(string: urlString.body)) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + } else { + ProgressView() + .frame(width: 200, height: 200) + } + }.frame(maxWidth: .infinity, alignment: urlString.isUser ? .trailing : .leading) default: EmptyView() } @@ -55,24 +70,40 @@ struct ChatView: View { HStack { TextField("Message", text: $message) - Button("Send") { - Task { - await viewModel.sendMessage(message) - self.message = "" - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - withAnimation(.easeInOut(duration: 1)) { - proxy.scrollTo(viewModel.events.first!.id, anchor: .bottom) + if message.isEmpty { + Button { + viewModel.showPhotoPicker = true + } label: { + Image(systemName: "photo.fill") + }.buttonStyle(.bordered) + } else { + Button { + Task { + await viewModel.sendMessage(message) + self.message = "" + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.easeInOut(duration: 1)) { + proxy.scrollTo(viewModel.events.first!.id, anchor: .bottom) + } } } - } - }.buttonStyle(.bordered) + } label: { + Image(systemName: "paperplane") + }.buttonStyle(.bordered) + } }.padding(8) } } } } } + .photosPicker(isPresented: $viewModel.showPhotoPicker, selection: $selectedPhoto) + .task(id: selectedPhoto) { + if let selectedPhoto { + await viewModel.sendImage(selectedPhoto) + } + } .alert(isPresented: $viewModel.errorContainer.hasError) { Alert(title: Text("Error"), message: Text(viewModel.errorContainer.text)) } @@ -100,7 +131,6 @@ struct ChatView: View { } final class ChatViewModel: ObservableObject { - private var memberId: String? private var cursor: String? = nil private let clientManager = ClientManager.shared @@ -108,6 +138,7 @@ final class ChatViewModel: ObservableObject { @Published var events: [VGPersistentConversationEvent] = [] @Published var showInviteUser = false + @Published var showPhotoPicker = false @Published var isLoading = false @Published var errorContainer = (hasError: false, text: "") @@ -143,22 +174,8 @@ final class ChatViewModel: ObservableObject { } } - private func getEvents() async -> [VGPersistentConversationEvent] { - do { - let params = VGGetConversationEventsParameters(order: .desc, pageSize: cursorSize, cursor: cursor) - let eventsPage = try await clientManager.client.getConversationEvents(conversationId, parameters: params) - cursor = eventsPage.nextCursor - return eventsPage.events - } catch { - await MainActor.run { - errorContainer = (true, error.localizedDescription) - } - } - - return [] - } - func loadEarlierEvents() async { + guard cursor != nil else { return } let earlierEvents = await getEvents() await MainActor.run { @@ -178,6 +195,28 @@ final class ChatViewModel: ObservableObject { } } + func sendImage(_ selectedItem: PhotosPickerItem) async { + do { + guard let authToken = clientManager.token else { + errorContainer = (true, "Auth Token Missing") + return + } + + guard let imageData = try await selectedItem.loadTransferable(type: Data.self), + let mimeType = selectedItem.supportedContentTypes.first?.preferredMIMEType else { + errorContainer = (true, "Error Loading Image") + return + } + + let uploadResponse: ImageUpload.Response = try await RemoteLoader.multipart(path: ImageUpload.chatPath, mimeType: mimeType, authToken: authToken, data: imageData) + + _ = try await clientManager.client.sendMessageImageEvent(conversationId, imageUrl: URL(string: uploadResponse.imageURL)!) + + } catch { + errorContainer = (true, error.localizedDescription) + } + } + func generateDisplayText(_ event: VGPersistentConversationEvent) -> (body: String, isUser: Bool) { var from = "System" @@ -204,6 +243,15 @@ final class ChatViewModel: ObservableObject { } return ("\(from) \(messageTextEvent.body.text)", isUser) + case .messageImage: + let messageImageEvent = event as! VGMessageImageEvent + var isUser = false + if let userInfo = messageImageEvent.from as? VGEmbeddedInfo { + isUser = userInfo.memberId == memberId + from = isUser ? "" : "\(userInfo.user.name): " + } + + return (messageImageEvent.body.imageUrl, isUser) default: return ("", false) } @@ -211,10 +259,25 @@ final class ChatViewModel: ObservableObject { // MARK: - Private + private func getEvents() async -> [VGPersistentConversationEvent] { + do { + let params = VGGetConversationEventsParameters(order: .desc, pageSize: cursorSize, cursor: cursor) + let eventsPage = try await clientManager.client.getConversationEvents(conversationId, parameters: params) + cursor = eventsPage.nextCursor + return eventsPage.events + } catch { + await MainActor.run { + errorContainer = (true, error.localizedDescription) + } + } + + return [] + } + private func getMemberId() async { do { let member = try await clientManager.client.getConversationMember(conversationId, memberId: "me") - + if member.state == .joined { memberId = member.id return diff --git a/client-ios/Vapp/Helpers/ClientManager.swift b/client-ios/Vapp/Helpers/ClientManager.swift index efbcb6c..455a6c0 100644 --- a/client-ios/Vapp/Helpers/ClientManager.swift +++ b/client-ios/Vapp/Helpers/ClientManager.swift @@ -20,7 +20,7 @@ final class ClientManager: NSObject, ObservableObject { var users: [Users.User] = [] // Chat Publisher - private var handledEventKinds: Set = [.memberInvited, .memberJoined, .memberLeft, .messageText] + private var handledEventKinds: Set = [.memberInvited, .memberJoined, .memberLeft, .messageText, .messageImage] private let messageSubject = PassthroughSubject() public var onEvent: AnyPublisher { messageSubject @@ -55,7 +55,7 @@ final class ClientManager: NSObject, ObservableObject { // MARK: - Public - public func auth(username: String, password: String, displayName: String? = nil, path: String, shouldStoreCredentials: Bool = true) async throws { + public func auth(username: String, password: String, displayName: String? = nil, path: String) async throws { let body = Auth.Body(name: username, password: password, displayName: displayName) let authResponse: Auth.Response = try await RemoteLoader.post(path: path, body: body) @@ -63,9 +63,7 @@ final class ClientManager: NSObject, ObservableObject { self.user = authResponse.user try await client?.createSession(authResponse.token) - if shouldStoreCredentials { - storeCredentials(username: username, password: password) - } + storeCredentials(username: username, password: password) await MainActor.run { isAuthed = true @@ -75,17 +73,13 @@ final class ClientManager: NSObject, ObservableObject { public func attemptStoredLogIn() async { if let credentials = getCredentials() { - try? await auth(username: credentials.0, password: credentials.1, path: Auth.loginPath, shouldStoreCredentials: false) + try? await auth(username: credentials.0, password: credentials.1, path: Auth.loginPath) } } public func logout() async throws { - if let username = user?.name { - try await client.deleteSession() - deleteCredentials(username: username) - } else { - // TODO: throw error - } + try await client.deleteSession() + deleteCredentials() } // MARK: - Private @@ -145,6 +139,7 @@ extension ClientManager: VGClientDelegate { extension ClientManager { private func storeCredentials(username: String, password: String) { + deleteCredentials() if let passwordData = password.data(using: .utf8) { let keychainItem = [ kSecClass: kSecClassInternetPassword, @@ -183,13 +178,12 @@ extension ClientManager { } } - private func deleteCredentials(username: String) { + private func deleteCredentials() { let query = [ - kSecClass: kSecClassInternetPassword, - kSecAttrServer: Constants.keychainServer, - kSecAttrAccount: username + kSecClass: kSecClassInternetPassword ] as CFDictionary SecItemDelete(query) + print("Keychain deleting attempted") } } diff --git a/client-ios/Vapp/SettingsView.swift b/client-ios/Vapp/SettingsView.swift index 9f994e5..c1621a3 100644 --- a/client-ios/Vapp/SettingsView.swift +++ b/client-ios/Vapp/SettingsView.swift @@ -11,9 +11,9 @@ import PhotosUI struct SettingsView: View { @ObservedObject var homeViewModel: HomeViewModel @ObservedObject var viewModel = SettingsViewModel() - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss - @State var selectedPhoto: PhotosPickerItem? + @State private var selectedPhoto: PhotosPickerItem? var body: some View { NavigationStack { @@ -21,7 +21,7 @@ struct SettingsView: View { if viewModel.isLoading { ProgressView() } else { - PhotosPicker(selection: $selectedPhoto) { + PhotosPicker(selection: $selectedPhoto, matching: .images) { ZStack { if let url = viewModel.imageURL { AsyncImage(url: url) { phase in diff --git a/client-ios/Vapp/UsersView.swift b/client-ios/Vapp/UsersView.swift index 80fab1c..091adf7 100644 --- a/client-ios/Vapp/UsersView.swift +++ b/client-ios/Vapp/UsersView.swift @@ -16,6 +16,28 @@ struct UsersView: View { VStack { List(viewModel.users) { user in HStack { + if let url = user.imageURL { + AsyncImage(url: URL(string: url)) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(8) + } else { + ProgressView() + .frame(width: 50, height: 50) + } + } + } else { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .padding(8) + } Text(user.name) Spacer() Button(action: {