diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index becfb6e..0000000 --- a/Package.resolved +++ /dev/null @@ -1,41 +0,0 @@ -{ - "pins" : [ - { - "identity" : "ios-ai-answer-assistant", - "kind" : "remoteSourceControl", - "location" : "https://github.com/QuickBlox/ios-ai-answer-assistant.git", - "state" : { - "revision" : "83da3d2846129853ad4a626205f7fea7edc933c2", - "version" : "2.0.0" - } - }, - { - "identity" : "ios-ai-rephrase", - "kind" : "remoteSourceControl", - "location" : "https://github.com/QuickBlox/ios-ai-rephrase.git", - "state" : { - "revision" : "633d1ce0219d0e48dd5773716e631233a5abe017", - "version" : "2.0.0" - } - }, - { - "identity" : "ios-ai-translate", - "kind" : "remoteSourceControl", - "location" : "https://github.com/QuickBlox/ios-ai-translate.git", - "state" : { - "revision" : "52a058215c03fa101c6810b692b80be2b07fc8ed", - "version" : "2.0.0" - } - }, - { - "identity" : "ios-quickblox-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/QuickBlox/ios-quickblox-sdk", - "state" : { - "revision" : "54fa973bf6a788529ae1b39ba5e301db94218385", - "version" : "2.19.0" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index 967849c..cbfbdc3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ // swift-tools-version: 5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. + import PackageDescription let package = Package( @@ -16,10 +17,10 @@ let package = Package( targets: ["QuickBloxUIKit", "QuickBloxData", "QuickBloxDomain"]), ], dependencies: [ - .package(url: "https://github.com/QuickBlox/ios-quickblox-sdk", .upToNextMajor(from: "2.19.0")), - .package(url: "https://github.com/QuickBlox/ios-ai-answer-assistant.git", .upToNextMajor(from: "2.0.0")), - .package(url: "https://github.com/QuickBlox/ios-ai-translate.git", .upToNextMajor(from: "2.0.0")), - .package(url: "https://github.com/QuickBlox/ios-ai-rephrase.git", .upToNextMajor(from: "2.0.0")) + .package(url: "https://github.com/QuickBlox/ios-quickblox-sdk", .upToNextMajor(from: "2.21.0")), + .package(url: "https://github.com/QuickBlox/ios-ai-answer-assistant.git", .upToNextMajor(from: "2.1.0")), + .package(url: "https://github.com/QuickBlox/ios-ai-translate.git", .upToNextMajor(from: "2.1.0")), + .package(url: "https://github.com/QuickBlox/ios-ai-rephrase.git", .upToNextMajor(from: "2.1.0")) ], targets: [ .target( @@ -51,5 +52,13 @@ let package = Package( "QuickBloxData", "QuickBloxLog"], resources: [.process("Resources")]), + .testTarget( + name: "QuickBloxUIKitIntegrationTests", + dependencies: ["QuickBloxUIKit", + "QuickBloxData", + "QuickBloxLog", + .product(name: "Quickblox", + package: "ios-quickblox-sdk")], + resources: [.process("Resources")]), ] ) diff --git a/Sources/QuickBloxData/DTO/AI/RemoteAnswerAssistMessageDTO.swift b/Sources/QuickBloxData/DTO/AI/RemoteAnswerAssistMessageDTO.swift new file mode 100644 index 0000000..7e6907f --- /dev/null +++ b/Sources/QuickBloxData/DTO/AI/RemoteAnswerAssistMessageDTO.swift @@ -0,0 +1,25 @@ +// +// RemoteAnswerAssistMessageDTO.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import QuickBloxDomain +import Foundation + +/// This is a DTO model for interactions with the Answer Assist Message models in remote storage. +public struct RemoteAnswerAssistMessageDTO { + var id = "" + var smartChatAssistantId = "" + var message = "" + var history: [RemoteAnswerAssistHistoryMessageDTO] = [] +} + +/// This is a DTO model for interactions with the Answer Assist History Message models in remote storage. +public struct RemoteAnswerAssistHistoryMessageDTO { + var id = "" + var role: AIMessageRole = .user + var message = "" +} diff --git a/Sources/QuickBloxData/DTO/AI/RemoteTranslateMessageDTO.swift b/Sources/QuickBloxData/DTO/AI/RemoteTranslateMessageDTO.swift new file mode 100644 index 0000000..f87f2d9 --- /dev/null +++ b/Sources/QuickBloxData/DTO/AI/RemoteTranslateMessageDTO.swift @@ -0,0 +1,16 @@ +// +// RemoteTranslateMessageDTO.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Foundation + +public struct RemoteTranslateMessageDTO { + var id = "" + var smartChatAssistantId = "" + var message = "" + var languageCode = "" +} diff --git a/Sources/QuickBloxData/Exception/DataSourceException.swift b/Sources/QuickBloxData/Exception/DataSourceException.swift index cf9c44b..47c4c25 100644 --- a/Sources/QuickBloxData/Exception/DataSourceException.swift +++ b/Sources/QuickBloxData/Exception/DataSourceException.swift @@ -19,6 +19,9 @@ public enum DataSourceException: Error, Equatable { /// Would be thrown when an operation is attempted on an record that does not exist in the data source. case notFound(description:String = "") + + /// Would be thrown when attempting to access and without authentication credentials to do so. + case unauthorised(description:String = "") } extension DataSourceException: LocalizedError { @@ -32,6 +35,8 @@ extension DataSourceException: LocalizedError { description = ("Already exist.", reason) case .notFound(let reason): description = ("Not found.", reason) + case .unauthorised(let reason): + description = ("Unauthorised.", reason) } return description.info + "" + description.reason diff --git a/Sources/QuickBloxData/Mapper/Error+RepositoryException.swift b/Sources/QuickBloxData/Mapper/Error+RepositoryException.swift index 13a725f..e8a58ce 100644 --- a/Sources/QuickBloxData/Mapper/Error+RepositoryException.swift +++ b/Sources/QuickBloxData/Mapper/Error+RepositoryException.swift @@ -32,6 +32,8 @@ extension Error { return RepositoryException.alreadyExist(description: info) case .notFound(description: let info): return RepositoryException.notFound(description: info) + case .unauthorised(description: let description): + return RepositoryException.unauthorised(description) } } diff --git a/Sources/QuickBloxData/RepositoriesFabric.swift b/Sources/QuickBloxData/RepositoriesFabric.swift index ce0adce..198fb0e 100644 --- a/Sources/QuickBloxData/RepositoriesFabric.swift +++ b/Sources/QuickBloxData/RepositoriesFabric.swift @@ -43,4 +43,8 @@ public class RepositoriesFabric { static public var permissions: PermissionsRepository { PermissionsRepository(repo: Service.permissions) } + + static public var ai: AIRepository { + AIRepository(remote: Service.remote) + } } diff --git a/Sources/QuickBloxData/Repository/AIRepository.swift b/Sources/QuickBloxData/Repository/AIRepository.swift new file mode 100644 index 0000000..b66f48d --- /dev/null +++ b/Sources/QuickBloxData/Repository/AIRepository.swift @@ -0,0 +1,64 @@ +// +// AIRepository.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Foundation +import QuickBloxDomain + +/// This is a class that implements the ``AIRepositoryProtocol`` protocol and contains methods and properties that allow it to interact with the ``AnswerAssistMessage`` items. +public class AIRepository { + private let remote: RemoteDataSourceProtocol + + public init(remote: RemoteDataSourceProtocol) { + self.remote = remote + } +} + +extension AIRepository: AIRepositoryProtocol { + + public func answerAssist(message entity: AnswerAssistMessage) async throws -> String { + do { + return try await remote.answerAssist(message: RemoteAnswerAssistMessageDTO(entity)) + } catch { + throw try error.repositoryException + } + } + + public func translate(message entity: TranslateMessage) async throws -> String { + do { + return try await remote.translate(message: RemoteTranslateMessageDTO(entity)) + } catch { + throw try error.repositoryException + } + } +} + +private extension RemoteAnswerAssistMessageDTO { + init(_ value: AnswerAssistMessage) { + id = value.id + smartChatAssistantId = value.smartChatAssistantId + message = value.message + history = value.history.compactMap({ RemoteAnswerAssistHistoryMessageDTO($0) }) + } +} + +private extension RemoteAnswerAssistHistoryMessageDTO { + init(_ value: AnswerAssistHistoryMessage) { + id = value.id + role = value.role + message = value.message + } +} + +private extension RemoteTranslateMessageDTO { + init(_ value: TranslateMessage) { + id = value.id + message = value.message + smartChatAssistantId = value.smartChatAssistantId + languageCode = value.languageCode + } +} diff --git a/Sources/QuickBloxData/Repository/UsersRepository.swift b/Sources/QuickBloxData/Repository/UsersRepository.swift index c8ef395..0b95f42 100644 --- a/Sources/QuickBloxData/Repository/UsersRepository.swift +++ b/Sources/QuickBloxData/Repository/UsersRepository.swift @@ -116,7 +116,7 @@ extension UsersRepository: UsersRepositoryProtocol { public func get(usersFromRemote fullName: String) async throws -> [User] { do { - let pagination = Pagination(skip: 0, limit: 30) + let pagination = Pagination(skip: 0, limit: 100) let withFullName = RemoteUsersDTO(name:fullName, pagination: pagination) let data = try await remote.get(users: withFullName) return data.users.map { User($0) } diff --git a/Sources/QuickBloxData/Source/Entity/AI/AnswerAssistMessage.swift b/Sources/QuickBloxData/Source/Entity/AI/AnswerAssistMessage.swift new file mode 100644 index 0000000..fc6b402 --- /dev/null +++ b/Sources/QuickBloxData/Source/Entity/AI/AnswerAssistMessage.swift @@ -0,0 +1,66 @@ +// +// AnswerAssistMessage.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import QuickBloxDomain +import Foundation + +/// Contain methods and properties that describe an Answer Assist Message. +/// +/// This is an active model that conforms to the ``AnswerAssistMessageEntity`` protocol. +public struct AnswerAssistMessage: AnswerAssistMessageEntity { + public typealias AnswerAssistHistoryMessageItem = AnswerAssistHistoryMessage + public var id: String = UUID().uuidString + public var smartChatAssistantId: String + public var message: String + public var history: [AnswerAssistHistoryMessage] + + public init(message: String, + history: [AnswerAssistHistoryMessage], + smartChatAssistantId: String) { + self.smartChatAssistantId = smartChatAssistantId + self.message = message + self.history = history + } +} + +public extension AnswerAssistMessage { + init(_ value: T) { + self.init(message: value.message, + history: value.history.map({ AnswerAssistHistoryMessage($0) }), + smartChatAssistantId: value.smartChatAssistantId) + } +} + +/// Contain methods and properties that describe an Answer Assist History Message. +/// +/// This is an active model that conforms to the ``AnswerAssistHistoryMessageEntity`` protocol. +public struct AnswerAssistHistoryMessage: AnswerAssistHistoryMessageEntity { + public var id: String = UUID().uuidString + public var role: AIMessageRole + public var message: String + + public init(role: AIMessageRole, + message: String) { + self.role = role + self.message = message + } +} + +public extension AnswerAssistHistoryMessage { + init(_ value: T) { + self.init(role: value.role, + message: value.message) + } +} + +public extension AnswerAssistHistoryMessage { + init(_ value: T) { + self.init(role: value.isOwnedByCurrentUser ? .user : .assistant, + message: value.text) + } +} diff --git a/Sources/QuickBloxData/Source/Entity/AI/TranslateMessage.swift b/Sources/QuickBloxData/Source/Entity/AI/TranslateMessage.swift new file mode 100644 index 0000000..4ef15e6 --- /dev/null +++ b/Sources/QuickBloxData/Source/Entity/AI/TranslateMessage.swift @@ -0,0 +1,38 @@ +// +// TranslateMessage.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Quickblox +import QuickBloxDomain +import Foundation + +/// Contain methods and properties that describe a Translate Message. +/// +/// This is an active model that conforms to the ``TranslateMessageEntity`` protocol. +public struct TranslateMessage: TranslateMessageEntity { + public var id: String = UUID().uuidString + public var message: String + public var smartChatAssistantId: String + public var languageCode: String + + public init(message: String, + smartChatAssistantId: String, + languageCode: String) { + self.message = message + self.smartChatAssistantId = smartChatAssistantId + self.languageCode = languageCode + } +} + +public extension TranslateMessage { + init(message: String, + smartChatAssistantId: String) { + self.message = message + self.smartChatAssistantId = smartChatAssistantId + self.languageCode = QBAILanguage.english.rawValue + } +} diff --git a/Sources/QuickBloxData/Source/Remote/API/API.swift b/Sources/QuickBloxData/Source/Remote/API/API.swift index 5169275..699b986 100644 --- a/Sources/QuickBloxData/Source/Remote/API/API.swift +++ b/Sources/QuickBloxData/Source/Remote/API/API.swift @@ -50,4 +50,5 @@ struct API { let users = APIUsers() let messages = APIMessages() let files = APIFiles() + let ai = APIAI() } diff --git a/Sources/QuickBloxData/Source/Remote/API/APIAI.swift b/Sources/QuickBloxData/Source/Remote/API/APIAI.swift new file mode 100644 index 0000000..b059cdc --- /dev/null +++ b/Sources/QuickBloxData/Source/Remote/API/APIAI.swift @@ -0,0 +1,89 @@ +// +// APIAI.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Quickblox + +struct APIAI { + // Quickblox Server API + func answerAssist(with message: QBAIAnswerAssistMessage) async throws -> String { + let result = try await QB.ai.answerAssist(withSmartChatAssistantId: message.smartChatAssistantId, + messageToAssist: message.message, + history: message.history) + return result.answer + } + + func translate(with message: QBAITranslateMessage) async throws -> String { + let result = try await QB.ai.translate(withSmartChatAssistantId: message.smartChatAssistantId, + textToTranslate: message.message, + languageCode: message.languageCode) + return result.answer + } +} + + +import QBAIAnswerAssistant + +extension APIAI { + // Quickblox QBAIAnswerAssistant Library + func answerAssist(with content: [RemoteMessageDTO], settings: QBAIAnswerAssistant.AISettings) async throws -> String { + + var aiSettings = settings + + if aiSettings.serverPath.isEmpty == false { + guard let qbToken = QBSession.current.sessionDetails?.token else { + throw DataSourceException.unauthorised(description: "") + } + aiSettings.token = qbToken + } + + let messages: [QBAIAnswerAssistant.AIMessage] = content.compactMap { message in + if message.isOwnedByCurrentUser { + return QBAIAnswerAssistant.AIMessage(role: .other, text: message.text) + } else { + return QBAIAnswerAssistant.AIMessage(role: .me, text: message.text) + } + } + + return try await QBAIAnswerAssistant.createAnswer(to: messages, + using: aiSettings) + } +} + + +import QBAITranslate + +extension APIAI { + // Quickblox QBAITranslate Library + func translate(with text: String, content: [RemoteMessageDTO], settings: QBAITranslate.AISettings) async throws -> String { + + var aiSettings = settings + + if settings.serverPath.isEmpty == false { + guard let qbToken = QBSession.current.sessionDetails?.token else { + throw DataSourceException.unauthorised(description: "") + } + aiSettings.token = qbToken + } + + var messages: [QBAITranslate.AIMessage] = [] + + messages = content.compactMap { message in + if message.isOwnedByCurrentUser { + return QBAITranslate.AIMessage(role: .other, text: message.text) + } else { + return QBAITranslate.AIMessage(role: .me, text: message.text) + } + } + + messages = [] + + return try await QBAITranslate.translate(text: text, + history: messages, + using: aiSettings) + } +} diff --git a/Sources/QuickBloxData/Source/Remote/Extension/QBAnswerAssistHistoryMessage+RemoteAnswerAssistMessageDTO.swift b/Sources/QuickBloxData/Source/Remote/Extension/QBAnswerAssistHistoryMessage+RemoteAnswerAssistMessageDTO.swift new file mode 100644 index 0000000..c262386 --- /dev/null +++ b/Sources/QuickBloxData/Source/Remote/Extension/QBAnswerAssistHistoryMessage+RemoteAnswerAssistMessageDTO.swift @@ -0,0 +1,24 @@ +// +// QBAnswerAssistMessage+RemoteAnswerAssistMessageDTO.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Quickblox + +extension QBAIAnswerAssistHistoryMessage { + convenience init(_ value: RemoteAnswerAssistHistoryMessageDTO) { + self.init(role: value.role == .user ? .user : .assistant, + message: value.message) + } +} + +extension QBAIAnswerAssistMessage { + convenience init(_ value: RemoteAnswerAssistMessageDTO) { + self.init(message: value.message, + smartChatAssistantId: value.smartChatAssistantId, + history: value.history.compactMap({ QBAIAnswerAssistHistoryMessage($0) })) + } +} diff --git a/Sources/QuickBloxData/Source/Remote/Extension/QBTranslateMessage+RemoteTranslateMessageDTO.swift b/Sources/QuickBloxData/Source/Remote/Extension/QBTranslateMessage+RemoteTranslateMessageDTO.swift new file mode 100644 index 0000000..bbb7421 --- /dev/null +++ b/Sources/QuickBloxData/Source/Remote/Extension/QBTranslateMessage+RemoteTranslateMessageDTO.swift @@ -0,0 +1,17 @@ +// +// QBTranslateMessage+RemoteTranslateMessageDTO.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Quickblox + +extension QBAITranslateMessage { + convenience init(_ value: RemoteTranslateMessageDTO) { + self.init(message: value.message, + smartChatAssistantId: value.smartChatAssistantId, + languageCode: value.languageCode) + } +} diff --git a/Sources/QuickBloxData/Source/Remote/RemoteDataSource.swift b/Sources/QuickBloxData/Source/Remote/RemoteDataSource.swift index da82a22..2b156b3 100644 --- a/Sources/QuickBloxData/Source/Remote/RemoteDataSource.swift +++ b/Sources/QuickBloxData/Source/Remote/RemoteDataSource.swift @@ -749,3 +749,47 @@ extension RemoteDataSource { } } } + +//MARK: AI +extension RemoteDataSource { + // Quickblox Server API + func answerAssist(message dto: RemoteAnswerAssistMessageDTO) async throws -> String { + do { + return try await api.ai.answerAssist(with: QBAIAnswerAssistMessage(dto)) + } catch let nsError as NSError { + throw try nsError.remoteException + } catch { + throw DataSourceException.unexpected(error.localizedDescription) + } + + } + + func translate(message dto: RemoteTranslateMessageDTO) async throws -> String { + do { + return try await api.ai.translate(with: QBAITranslateMessage(dto)) + } catch let nsError as NSError { + throw try nsError.remoteException + } catch { + throw DataSourceException.unexpected(error.localizedDescription) + } + } +} + +//MARK: AI Quickblox QBAIAnswerAssistant Library +import QBAIAnswerAssistant +extension RemoteDataSource { + func answerAssist(with content: [RemoteMessageDTO], + settings: QBAIAnswerAssistant.AISettings) async throws -> String { + return try await api.ai.answerAssist(with: content, settings: settings) + } +} + +//MARK: AI Quickblox QBAITranslate Library +import QBAITranslate +extension RemoteDataSource { + func translate(with text: String, + content: [RemoteMessageDTO], + settings: QBAITranslate.AISettings) async throws -> String { + return try await api.ai.translate(with: text, content: content, settings: settings) + } +} diff --git a/Sources/QuickBloxData/Source/Remote/RemoteDataSourceProtocol.swift b/Sources/QuickBloxData/Source/Remote/RemoteDataSourceProtocol.swift index f323116..7997b42 100644 --- a/Sources/QuickBloxData/Source/Remote/RemoteDataSourceProtocol.swift +++ b/Sources/QuickBloxData/Source/Remote/RemoteDataSourceProtocol.swift @@ -87,7 +87,7 @@ public protocol RemoteDataSourceProtocol { /// - Throws: ``DataSourceException``**.notFound** when an message item is missing from remote storage. /// - Throws: ``RemoteDataSourceException``**.restrictedAccess** when appropriate permissions to perform this operation is absent. func update(message dto: RemoteMessageDTO) async throws -> RemoteMessageDTO -// + /// Remove a message from a remote storage. /// - Parameter dto: message's dto item. /// @@ -151,6 +151,7 @@ public protocol RemoteDataSourceProtocol { //MARK: Events var eventPublisher: AnyPublisher { get async } + //MARK: Connection var connectionPublisher: AnyPublisher { get } @@ -160,4 +161,20 @@ public protocol RemoteDataSourceProtocol { func disconnect() async throws func checkConnection() async throws -> ConnectionState + + //MARK: AI + + /// Retrieve an ai answer assist from a remote storage. + /// - Parameter message: Message item you want to get answer for. + /// - Returns: answer. + /// + /// - Throws: ``DataSourceException``**.notFound** when an answer is missing from remote storage. + func answerAssist(message dto: RemoteAnswerAssistMessageDTO) async throws -> String + + /// Retrieve a translate from a remote storage. + /// - Parameter message: Message item to translate. + /// - Returns: translate. + /// + /// - Throws: ``DataSourceException``**.notFound** when a translate is missing from remote storage. + func translate(message dto: RemoteTranslateMessageDTO) async throws -> String } diff --git a/Sources/QuickBloxDomain/Entity/AI/AnswerAssistMessageEntity.swift b/Sources/QuickBloxDomain/Entity/AI/AnswerAssistMessageEntity.swift new file mode 100644 index 0000000..a60dfbf --- /dev/null +++ b/Sources/QuickBloxDomain/Entity/AI/AnswerAssistMessageEntity.swift @@ -0,0 +1,43 @@ +// +// AnswerAssistMessageEntity.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Foundation + +/// Define a set of predefined options for the type of AI Role +public enum AIMessageRole: String, Codable { + case user + case assistant +} + +/// Describes a set of data and functions that represent an AnswerAssistHistoryMessage entity. +public protocol AnswerAssistHistoryMessageEntity: Entity { + var id: String { get } + + var role: AIMessageRole { get } + + var message: String { get } + + init(role: AIMessageRole, message: String) +} + +/// Describes a set of data and functions that represent an AnswerAssistMessage entity. +public protocol AnswerAssistMessageEntity: Entity { + associatedtype AnswerAssistHistoryMessageItem: AnswerAssistHistoryMessageEntity + + var id: String { get } + + var smartChatAssistantId: String { get } + + var message: String { get } + + var history: [AnswerAssistHistoryMessageItem] { get } + + init(message: String, + history: [AnswerAssistHistoryMessageItem], + smartChatAssistantId: String) +} diff --git a/Sources/QuickBloxDomain/Entity/AI/TranslateMessageEntity.swift b/Sources/QuickBloxDomain/Entity/AI/TranslateMessageEntity.swift new file mode 100644 index 0000000..bef38d4 --- /dev/null +++ b/Sources/QuickBloxDomain/Entity/AI/TranslateMessageEntity.swift @@ -0,0 +1,36 @@ +// +// TranslateMessageEntity.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Foundation + +/// Describes a set of data and functions that represent a TranslateMessage entity. +public protocol TranslateMessageEntity: Entity { + + var id: String { get } + + var message: String { get } + + var smartChatAssistantId: String { get } + + var languageCode: String { get } + + init(message: String, + smartChatAssistantId: String, + languageCode: String) +} + +//public extension TranslateMessageEntity { +// init(message: String, +// smartChatAssistantId: String, +// languageCode: String) { +// self.init(id: UUID().uuidString, +// message: message, +// smartChatAssistantId: smartChatAssistantId, +// languageCode: languageCode) +// } +//} diff --git a/Sources/QuickBloxDomain/Repository/AIRepositoryProtocol.swift b/Sources/QuickBloxDomain/Repository/AIRepositoryProtocol.swift new file mode 100644 index 0000000..0fdcadf --- /dev/null +++ b/Sources/QuickBloxDomain/Repository/AIRepositoryProtocol.swift @@ -0,0 +1,29 @@ +// +// AIRepositoryProtocol.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import Foundation + +/// Provides a set of methods for getting, saving and manipulating with ``AnswerAssistMessageEntity`` and ``TranslateMessageEntity`` items. +public protocol AIRepositoryProtocol { + associatedtype AnswerAssistMessageEntityItem: AnswerAssistMessageEntity + associatedtype TranslateMessageEntityItem: TranslateMessageEntity + + /// Retrieve an ai answer assist from a remote storage. + /// - Parameter message: ``AnswerAssistMessageEntity`` item you want to get answer for. + /// - Returns: answer. + /// + /// - Throws: ``RepositoryException``**.incorrectData** when wrong format of data, missing required fields, or providing incorrect values. + func answerAssist(message entity: AnswerAssistMessageEntityItem) async throws -> String + + /// Retrieve a translate from a remote storage. + /// - Parameter message: ``TranslateMessageEntity`` item to translate. + /// - Returns: translate. + /// + /// - Throws: ``RepositoryException``**.incorrectData** when a translate is missing from remote storage. + func translate(message entity: TranslateMessageEntityItem) async throws -> String +} diff --git a/Sources/QuickBloxDomain/UseCase/AI/AnswerAssist/AnswerAssist.swift b/Sources/QuickBloxDomain/UseCase/AI/AnswerAssist/AnswerAssist.swift index a04cd90..7b3ce27 100644 --- a/Sources/QuickBloxDomain/UseCase/AI/AnswerAssist/AnswerAssist.swift +++ b/Sources/QuickBloxDomain/UseCase/AI/AnswerAssist/AnswerAssist.swift @@ -2,18 +2,60 @@ // AnswerAssist.swift // QuickBloxUIKit // -// Created by Injoit on 04.08.2023. +// Created by Injoit on 16.05.2024. // Copyright © 2023 QuickBlox. All rights reserved. // -import Foundation -import Quickblox -import QBAIAnswerAssistant +import QuickBloxLog public protocol AIFeatureUseCaseProtocol { func execute() async throws -> String } +public class AIAnswerAssist: AIFeatureUseCaseProtocol +where AnswerAssistMessageEntityItem == Repo.AnswerAssistMessageEntityItem, + HistoryMessageItem == AnswerAssistMessageEntityItem.AnswerAssistHistoryMessageItem { + private let message: MessageItem + private let history: [MessageItem] + private let smartChatAssistantId: String + private let repo: Repo + + public init(message: MessageItem, history: [MessageItem], smartChatAssistantId: String, repo: Repo) { + self.message = message + self.history = history + self.smartChatAssistantId = smartChatAssistantId + self.repo = repo + } + + public func execute() async throws -> String { + + let messages: [HistoryMessageItem] = history.compactMap { message in + if message.isOwnedByCurrentUser { + return HistoryMessageItem(role: .assistant, message: message.text) + } else { + return HistoryMessageItem(role: .user, message: message.text) + } + } + + let answerAssistMessage = AnswerAssistMessageEntityItem(message: message.text, + history: messages, + smartChatAssistantId: smartChatAssistantId) + + do { + return try await repo.answerAssist(message: answerAssistMessage) + } catch { + prettyLog(error) + throw error + } + } +} + +import Quickblox +import QBAIAnswerAssistant + public class AnswerAssist: AIFeatureUseCaseProtocol { private let content: [any MessageEntity] private var settings: QBAIAnswerAssistant.AISettings @@ -34,9 +76,9 @@ public class AnswerAssist: AIFeatureUseCaseProtocol { let messages: [QBAIAnswerAssistant.AIMessage] = content.compactMap { message in if message.isOwnedByCurrentUser { - return QBAIAnswerAssistant.AIMessage(role: .me, text: message.text) - } else { return QBAIAnswerAssistant.AIMessage(role: .other, text: message.text) + } else { + return QBAIAnswerAssistant.AIMessage(role: .me, text: message.text) } } diff --git a/Sources/QuickBloxDomain/UseCase/AI/Translate/Translate.swift b/Sources/QuickBloxDomain/UseCase/AI/Translate/Translate.swift index 8e28ac5..afed572 100644 --- a/Sources/QuickBloxDomain/UseCase/AI/Translate/Translate.swift +++ b/Sources/QuickBloxDomain/UseCase/AI/Translate/Translate.swift @@ -2,11 +2,42 @@ // Translate.swift // QuickBloxUIKit // -// Created by Injoit on 08.08.2023. +// Created by Injoit on 16.05.2024. // Copyright © 2023 QuickBlox. All rights reserved. // -import Foundation +import QuickBloxLog + +public class AITranslate: AIFeatureUseCaseProtocol +where TranslateMessageEntityItem == Repo.TranslateMessageEntityItem { + private let text: String + private let smartChatAssistantId: String + private let languageCode: String + private let repo: Repo + + public init(_ text: String, smartChatAssistantId: String, languageCode: String, repo: Repo) { + self.text = text + self.smartChatAssistantId = smartChatAssistantId + self.languageCode = languageCode + self.repo = repo + } + + public func execute() async throws -> String { + + let translate = TranslateMessageEntityItem(message: text, + smartChatAssistantId: smartChatAssistantId, + languageCode: languageCode) + + do { + return try await repo.translate(message: translate) + } catch { + prettyLog(error) + throw error + } + } +} + import Quickblox import QBAITranslate @@ -30,14 +61,17 @@ public class Translate: AIFeatureUseCaseProtocol { settings.token = qbToken } - let messages: [QBAITranslate.AIMessage] = content.compactMap { message in + var messages: [QBAITranslate.AIMessage] = [] + + messages = content.compactMap { message in if message.isOwnedByCurrentUser { - return QBAITranslate.AIMessage(role: .me, text: message.text) - } else { return QBAITranslate.AIMessage(role: .other, text: message.text) + } else { + return QBAITranslate.AIMessage(role: .me, text: message.text) } } + messages = [] return try await QBAITranslate.translate(text: text, history: messages, diff --git a/Sources/QuickBloxUIKit/Resources/PrivacyInfo.xcprivacy b/Sources/QuickBloxUIKit/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..bd08700 --- /dev/null +++ b/Sources/QuickBloxUIKit/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,27 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/DialogInfoViewModifier.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/DialogInfoViewModifier.swift index ed3ffff..8da9478 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/DialogInfoViewModifier.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/DialogInfoViewModifier.swift @@ -73,11 +73,12 @@ public struct InfoDialogAvatar: View { height: settings.height, isHidden: settings.isHidden) - Text(viewModel.dialog.validName) + Text(viewModel.dialog.name) .font(settings.font) .foregroundColor(settings.color) + .multilineTextAlignment(.center) + .padding() } - .frame(height: settings.containerHeight) .padding(settings.padding) } } @@ -184,7 +185,7 @@ public struct EditDialogAlert: ViewModifier { }) }) - .if(isIPad == true && isPresented == true, transform: { view in + .if((isIPad == true || isMac == true) && isPresented == true, transform: { view in ZStack { view.disabled(true) .overlay( diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogInfoView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogInfoView.swift index dacf75b..cb0ec45 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogInfoView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogInfoView.swift @@ -31,7 +31,7 @@ public struct GroupDialogInfoView: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogNonEditInfoView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogNonEditInfoView.swift index ec5bac1..7717c1b 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogNonEditInfoView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/GroupDialogNonEditInfoView.swift @@ -26,7 +26,7 @@ public struct GroupDialogNonEditInfoView: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/PrivateDialogInfoView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/PrivateDialogInfoView.swift index b52903c..da17f8c 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/PrivateDialogInfoView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogInfo/PrivateDialogInfoView.swift @@ -26,7 +26,7 @@ public struct PrivateDialogInfoView: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogViewViewModifier.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogViewViewModifier.swift index 2b0c0c6..6eda79e 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogViewViewModifier.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/DialogViewViewModifier.swift @@ -43,29 +43,29 @@ struct DialogHeaderToolbarContent: ToolbarContent { public var body: some ToolbarContent { - ToolbarItem(placement: .navigationBarLeading) { - - if feature.startScreen.screen == .dialog, - dialogHeaderSettings.leftButton.hidden == false, - isForward == false { - Button { - onDismiss() - } label: { - if let title = dialogHeaderSettings.leftButton.title { - Text(title).foregroundColor(dialogHeaderSettings.leftButton.color) - } else { - dialogHeaderSettings.leftButton.image - .resizable() - .scaledToFit() - .scaleEffect(dialogHeaderSettings.leftButton.scale) - .tint(dialogHeaderSettings.leftButton.color) - .padding(dialogHeaderSettings.leftButton.padding) - } - }.frame(width: 32, height: 44) - } + ToolbarItem(placement: .navigationBarLeading) { + + if feature.startScreen.screen == .dialog, + dialogHeaderSettings.leftButton.hidden == false, + isForward == false { + Button { + onDismiss() + } label: { + if let title = dialogHeaderSettings.leftButton.title { + Text(title).foregroundColor(dialogHeaderSettings.leftButton.color) + } else { + dialogHeaderSettings.leftButton.image + .resizable() + .scaledToFit() + .scaleEffect(dialogHeaderSettings.leftButton.scale) + .tint(dialogHeaderSettings.leftButton.color) + .padding(dialogHeaderSettings.leftButton.padding) + } + }.frame(width: 32, height: 44) } + } - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .principal) { if isForward == false { @@ -78,14 +78,15 @@ struct DialogHeaderToolbarContent: ToolbarContent { do { avatar = try await dialog.avatar(scale: .avatar3x) } catch { prettyLog(error) } } - Text(dialog.validName) + Text(dialog.name) .font(dialogHeaderSettings.title.font) .foregroundColor(dialogHeaderSettings.title.color) + .lineLimit(1) + .truncationMode(.tail) } } } - ToolbarItem(placement: .principal) { if isForward { Text("\(selectedCount)" + " " + dialogHeaderSettings.selectedMessages(selectedCount)) @@ -104,7 +105,7 @@ struct DialogHeaderToolbarContent: ToolbarContent { } } else if dialogHeaderSettings.rightButton.hidden == false { ToolbarItem(placement: .navigationBarTrailing) { - Button { + Button() { onTapInfo() } label: { if let title = dialogHeaderSettings.rightButton.title { @@ -516,7 +517,8 @@ struct PreviewContextViewModifier: ViewModifier { actions: actions, isActive: $isActive ) - .opacity(0.05) + .background(Color.clear) + .blendMode(.destinationOver) ) } } @@ -564,7 +566,7 @@ struct CustomPreviewContextMenuView: UIViewRepresentable { func makeUIView(context: Context) -> UIView { let view = UIView() - view.backgroundColor = .systemBackground + view.backgroundColor = .clear view.addInteraction( UIContextMenuInteraction( delegate: context.coordinator @@ -599,6 +601,7 @@ struct CustomPreviewContextMenuView: UIViewRepresentable { if let preferredContentSize = self.view.preferredContentSize { hostingController.preferredContentSize = preferredContentSize } + hostingController.view.backgroundColor = .clear return hostingController }, actionProvider: { _ in UIMenu(title: "", children: self.view.actions) @@ -708,31 +711,31 @@ struct ButtonBuilder { } enum UIImageAxis { -case none, horizontal, vertical + case none, horizontal, vertical } extension UIImage { - func flipped(_ axis: UIImageAxis) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: size) - - return renderer.image { - let context = $0.cgContext - context.translateBy(x: size.width / 2, y: size.height / 2) - - switch axis { - case .horizontal: - context.scaleBy(x: -1, y: 1) - case .vertical: - context.scaleBy(x: 1, y: -1) - case .none: - context.scaleBy(x: 1, y: 1) - } - - context.translateBy(x: -size.width / 2, y: -size.height / 2) - - draw(at: .zero) - } - } + func flipped(_ axis: UIImageAxis) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { + let context = $0.cgContext + context.translateBy(x: size.width / 2, y: size.height / 2) + + switch axis { + case .horizontal: + context.scaleBy(x: -1, y: 1) + case .vertical: + context.scaleBy(x: 1, y: -1) + case .none: + context.scaleBy(x: 1, y: 1) + } + + context.translateBy(x: -size.width / 2, y: -size.height / 2) + + draw(at: .zero) + } + } } extension View { @@ -744,7 +747,7 @@ extension View { struct ViewDidLoadModifier: ViewModifier { @State private var viewDidLoad = false let action: (() -> Void)? - + func body(content: Content) -> some View { content .onAppear { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Forward/ForwardView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Forward/ForwardView.swift index 9c238fc..211557e 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Forward/ForwardView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Forward/ForwardView.swift @@ -32,7 +32,7 @@ public struct ForwardView: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/GroupDialogView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/GroupDialogView.swift index 5a31132..121a863 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/GroupDialogView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/GroupDialogView.swift @@ -233,13 +233,9 @@ public struct GroupDialogView switch dialog.type { case .group: if dialog.isOwnedByCurrentUser == true { - GroupDialogInfoView(DialogInfoViewModel(dialog)).onAppear { - isInfoPresented = false - } + GroupDialogInfoView(DialogInfoViewModel(dialog)) } else { - GroupDialogNonEditInfoView(DialogInfoViewModel(dialog)).onAppear { - isInfoPresented = false - } + GroupDialogNonEditInfoView(DialogInfoViewModel(dialog)) } default: EmptyView() @@ -248,7 +244,7 @@ public struct GroupDialogView } }) - .if(isIPad == true && isInfoPresented == true, transform: { view in + .if((isIPad == true || isMac == true) && isInfoPresented == true, transform: { view in view.sheet(isPresented: $isInfoPresented, content: { if let dialog = viewModel.dialog as? Dialog { switch dialog.type { @@ -274,18 +270,17 @@ public struct GroupDialogView ForwardView(viewModel: ForwardViewModel(messages: viewModel.selectedMessages as? [Message] ?? [])) { viewModel.cancelMessageAction() isForwardSuccess = true - }.onAppear { - isForwardPresented = false } } }) - .if(isIPad == true && isForwardPresented == true, transform: { view in + .if((isIPad == true || isMac == true) && isForwardPresented == true, transform: { view in view.sheet(isPresented: $isForwardPresented, content: { ForwardView(viewModel: ForwardViewModel(messages: viewModel.selectedMessages as? [Message] ?? [])) { viewModel.cancelMessageAction() isForwardSuccess = true - } }) + } + }) }) .modifier(DialogHeader(dialog: viewModel.dialog, @@ -389,9 +384,6 @@ public struct GroupDialogView } } }, onSelect: { item, actionType in - if viewModel.messagesActionState == actionType { - return - } viewModel.handleOnSelect(item, actionType: actionType) }, aiAnswerWaiting: $viewModel.waitingAnswer) .onAppear { @@ -422,7 +414,17 @@ public struct GroupDialogView } public var body: some View { - if isIPad { + if isIphone { + container() + .onViewDidLoad { + viewModel.sync() + } + .onDisappear { + viewModel.sendStopTyping() + viewModel.stopPlayng() + viewModel.unsync() + } + } else { NavigationStack { container() .onViewDidLoad { @@ -434,16 +436,6 @@ public struct GroupDialogView viewModel.unsync() } } - } else if isIphone { - container() - .onViewDidLoad { - viewModel.sync() - } - .onDisappear { - viewModel.sendStopTyping() - viewModel.stopPlayng() - viewModel.unsync() - } } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Members/RemoveMembersView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Members/RemoveMembersView.swift index 94ae846..5524201 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Members/RemoveMembersView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/Members/RemoveMembersView.swift @@ -29,7 +29,7 @@ public struct RemoveMembersView: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowAnimatedImage.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowAnimatedImage.swift new file mode 100644 index 0000000..541dcca --- /dev/null +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowAnimatedImage.swift @@ -0,0 +1,22 @@ +// +// MessageRowAnimatedImage.swift +// QuickBloxUIKit +// +// Created by Injoit on 24.01.2025. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import SwiftUI + +struct MessageRowAnimatedImage: UIViewRepresentable { + let image: UIImage? + + func makeUIView(context: Context) -> UIImageView { + let imageView = UIImageView() + return imageView + } + + func updateUIView(_ uiView: UIImageView, context: Context) { + uiView.image = image + } +} diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowName.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowName.swift index 1b2c82e..e430424 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowName.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/Element/MessageRowName.swift @@ -35,9 +35,8 @@ public struct MessageRowName: View { .font(settings.name.font) .padding(settings.inboundNamePadding) .task { - do { - let userName = try await message.userName - self.userName = regex.userName.isEmpty ? userName : (userName.isValid(regexes: [regex.userName]) == true ? userName : settings.name.unknown) + do { + self.userName = try await message.userName } catch { prettyLog(error) } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundAudioMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundAudioMessageRow.swift index 7b31008..93b58ce 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundAudioMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundAudioMessageRow.swift @@ -54,7 +54,9 @@ public struct InboundAudioMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -70,13 +72,37 @@ public struct InboundAudioMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - play() + messageContent() + .if(fileTuple?.url != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true) + .cornerRadius(settings.attachmentRadius, corners: settings.outboundForwardCorners), + preferredContentSize: settings.inboundAudioPreviewSize + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + CustomContextMenuAction(title: settings.save.title, + systemImage: settings.save.systemImage ?? "", tintColor: settings.save.color, flipped: nil, + attributes: nil) { + save() + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + play() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } if message.actionType == .none || @@ -101,43 +127,6 @@ public struct InboundAudioMessageRow: View { .padding(.bottom, actionSpacerBetweenRows()) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(fileTuple?.url != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true) - .cornerRadius(settings.attachmentRadius, corners: settings.outboundForwardCorners), - preferredContentSize: settings.inboundAudioPreviewSize - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - CustomContextMenuAction(title: settings.save.title, - systemImage: settings.save.systemImage ?? "", tintColor: settings.save.color, flipped: nil, - attributes: nil) { - save() - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundChatMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundChatMessageRow.swift index fc0c078..8118d82 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundChatMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundChatMessageRow.swift @@ -64,18 +64,6 @@ public struct InboundChatMessageRow: View { } else { aiMessageView() } - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @@ -85,7 +73,9 @@ public struct InboundChatMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -110,15 +100,15 @@ public struct InboundChatMessageRow: View { preferredContentSize: size ) { CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { onSelect(message, .reply) } CustomContextMenuAction(title: settings.forward.title, systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { + attributes: features.forward.enable == true + ? nil : .hidden) { onSelect(message, .forward) } } @@ -154,7 +144,9 @@ public struct InboundChatMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -180,53 +172,53 @@ public struct InboundChatMessageRow: View { preferredContentSize: size ) { CustomContextMenuAction(title: features.ai.ui.answerAssist.title, tintColor: features.ai.ui.answerAssist.color, flipped: nil, - attributes: features.ai.ui.robot.hidden == true && features.ai.answerAssist.enable == true - ? nil : .hidden) { - onAIFeature(.answerAssist, message) + attributes: features.ai.ui.robot.hidden == true && features.ai.answerAssist.enable == true + ? nil : .hidden) { + applyAIAnswerAssist() } CustomContextMenuAction(title: settings.reply.title, systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { + attributes: features.reply.enable == true + ? nil : .hidden) { onSelect(message, .reply) } CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { onSelect(message, .forward) } } }) - - if features.ai.translate.enable == true { - if features.forward.enable == true, - messagesActionState == .forward { + + if features.ai.translate.enable == true { + if features.forward.enable == true, + messagesActionState == .forward { + Text(message.translatedText.isEmpty == false && showOriginal == false ? features.ai.ui.translate.showOriginal : features.ai.ui.translate.showTranslation) + .lineLimit(1) + .foregroundColor(settings.infoForeground) + .font(settings.translateFont) + .padding(.trailing) + } else { + Button { + if features.ai.translate.enable == true { + if showOriginal == true && message.translatedText.isEmpty == false { + showOriginal = false + } else if showOriginal == false && message.translatedText.isEmpty == false { + showOriginal = true + } else { + onAIFeature(.translate, message) + } + } + } label: { Text(message.translatedText.isEmpty == false && showOriginal == false ? features.ai.ui.translate.showOriginal : features.ai.ui.translate.showTranslation) .lineLimit(1) .foregroundColor(settings.infoForeground) .font(settings.translateFont) .padding(.trailing) - } else { - Button { - if features.ai.translate.enable == true { - if showOriginal == true && message.translatedText.isEmpty == false { - showOriginal = false - } else if showOriginal == false && message.translatedText.isEmpty == false { - showOriginal = true - } else { - onAIFeature(.translate, message) - } - } - } label: { - Text(message.translatedText.isEmpty == false && showOriginal == false ? features.ai.ui.translate.showOriginal : features.ai.ui.translate.showTranslation) - .lineLimit(1) - .foregroundColor(settings.infoForeground) - .font(settings.translateFont) - .padding(.trailing) - }.buttonStyle(.plain) - } + }.buttonStyle(.plain) } + } } .frame(minWidth: features.ai.translate.enable == true ? features.ai.ui.translate.width : 0) @@ -252,7 +244,7 @@ public struct InboundChatMessageRow: View { Menu { if features.ai.answerAssist.enable == true { Button { - onAIFeature(.answerAssist, message) + applyAIAnswerAssist() } label: { Label(features.ai.ui.answerAssist.title, systemImage: "") }.buttonStyle(.plain) @@ -303,6 +295,16 @@ public struct InboundChatMessageRow: View { } return settings.spacerBetweenRows } + + fileprivate func applyAIAnswerAssist() { + let text = message.translatedText.isEmpty == false && + showOriginal == false ? message.translatedText : message.text + var question = Message(message) + question.text = text + if let question = question as? MessageItem { + onAIFeature(.answerAssist, question) + } + } } import QuickBloxData diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundFileMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundFileMessageRow.swift index fabb7ca..3bda8b6 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundFileMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundFileMessageRow.swift @@ -53,7 +53,9 @@ public struct InboundFileMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -68,13 +70,32 @@ public struct InboundFileMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - open() + messageContent() + .if(contentSize != nil && fileTuple?.url != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } if message.actionType == .none || message.actionType == .forward || @@ -97,37 +118,6 @@ public struct InboundFileMessageRow: View { .padding(.bottom, actionSpacerBetweenRows()) .id(message.id) - .if(contentSize != nil && fileTuple?.url != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, - height: contentSize?.height ?? 0.0) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundForwardedMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundForwardedMessageRow.swift index a118f46..32c9330 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundForwardedMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundForwardedMessageRow.swift @@ -244,7 +244,7 @@ public struct InboundForwardedMessageRow: View { attributes: features.ai.ui.robot.hidden == true && features.ai.answerAssist.enable == true ? nil : .hidden) { if let message = message as? DialogViewModel.DialogItem.MessageItem { - viewModel.applyAIAnswerAssist(message) + applyAIAnswerAssist(message) } } CustomContextMenuAction(title: settings.reply.title, @@ -322,7 +322,7 @@ public struct InboundForwardedMessageRow: View { if features.ai.answerAssist.enable == true { Button { if let message = message as? DialogViewModel.DialogItem.MessageItem { - viewModel.applyAIAnswerAssist(message) + applyAIAnswerAssist(message) } } label: { Label(features.ai.ui.answerAssist.title, systemImage: "") @@ -359,4 +359,11 @@ public struct InboundForwardedMessageRow: View { .fixedSize(horizontal: false, vertical: true) .id(message.id) } + + fileprivate func applyAIAnswerAssist(_ message: DialogViewModel.DialogItem.MessageItem) { + let text = message.translatedText.isEmpty == false && showOriginal == false ? message.translatedText : message.text + var question = Message(message) + question.text = text + viewModel.applyAIAnswerAssist(question) + } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundGIFMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundGIFMessageRow.swift index e422fed..eeac9f1 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundGIFMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundGIFMessageRow.swift @@ -46,7 +46,9 @@ public struct InboundGIFMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -61,13 +63,32 @@ public struct InboundGIFMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - open() + messageContent() + .if(contentSize != nil && fileTuple?.image != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } if message.actionType == .none || @@ -90,45 +111,25 @@ public struct InboundGIFMessageRow: View { .padding(.bottom, actionSpacerBetweenRows()) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(contentSize != nil && fileTuple?.image != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, - height: contentSize?.height ?? 0.0) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @ViewBuilder private func messageContent(forPreview: Bool = false) -> some View { ZStack { - if let image = fileTuple?.image { + if let url = fileTuple?.url, url.pathExtension.lowercased() == "gif", + let animatedImage = UIImage.animatedImage(from: url) { + + MessageRowAnimatedImage(image: animatedImage) + .fixedSize() + .clipped() + .cornerRadius(settings.attachmentRadius, corners: message.actionType == .reply && message.relatedId.isEmpty == false ? + settings.outboundForwardCorners : settings.inboundCorners) + .contentSize(onChange: { contentSize in + self.contentSize = contentSize + }) + + } else if let image = fileTuple?.image { Image(uiImage: image) .resizable() .scaledToFit() @@ -158,9 +159,6 @@ public struct InboundGIFMessageRow: View { SegmentedCircularBar(settings: settings.progressBar) } } - .contentSize(onChange: { contentSize in - self.contentSize = contentSize - }) } private func open() { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundImageMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundImageMessageRow.swift index 94051ab..088b735 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundImageMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundImageMessageRow.swift @@ -48,7 +48,9 @@ public struct InboundImageMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -62,13 +64,32 @@ public struct InboundImageMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - open() + messageContent() + .if(contentSize != nil && fileTuple?.image != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } if message.actionType == .none || @@ -92,38 +113,6 @@ public struct InboundImageMessageRow: View { .padding(.bottom, actionSpacerBetweenRows()) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(contentSize != nil && fileTuple?.image != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, - height: contentSize?.height ?? 0.0) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @@ -180,7 +169,7 @@ public struct InboundImageMessageRow: View { // onTap: { (_,_) in}, // onSelect: { (_,_) in}) // .previewDisplayName("Message") -// +// // InboundImageMessageRow(message: Message(id: UUID().uuidString, // dialogId: "1f2f3ds4d5d6d", // text: "Test text Message", diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundVideoMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundVideoMessageRow.swift index 2766670..be45767 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundVideoMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/InboundVideoMessageRow.swift @@ -48,7 +48,9 @@ public struct InboundVideoMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } MessageRowAvatar(message: message) @@ -63,13 +65,32 @@ public struct InboundVideoMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - open() + messageContent() + .if(contentSize != nil && fileTuple?.image != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } if message.actionType == .none || @@ -93,38 +114,6 @@ public struct InboundVideoMessageRow: View { .padding(.bottom, actionSpacerBetweenRows()) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(contentSize != nil && fileTuple?.image != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, - height: contentSize?.height ?? 0.0) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/MessageRowView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/MessageRowView.swift index fe1bcb1..d2ded1e 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/MessageRowView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/MessageRowView.swift @@ -124,10 +124,18 @@ extension MessageEntity { return .inboundImage } } else if isVideoMessage { - if isOwnedByCurrentUser { - return .outboundVideo + if fileInfo?.ext == .gif { + if isOwnedByCurrentUser { + return .outboundGIF + } else { + return .inboundGIF + } } else { - return .inboundVideo + if isOwnedByCurrentUser { + return .outboundVideo + } else { + return .inboundVideo + } } } else if isAudioMessage { if isOwnedByCurrentUser { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundAudioMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundAudioMessageRow.swift index b6fffad..6f22fce 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundAudioMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundAudioMessageRow.swift @@ -30,6 +30,8 @@ public struct OutboundAudioMessageRow: View { private var relatedTime: Date? = nil private var relatedStatus: MessageStatus? = nil + @State private var contentSize: CGSize? + private let onSelect: (_ item: MessageItem, _ actionType: MessageAction) -> Void public init(message: MessageItem, @@ -59,7 +61,9 @@ public struct OutboundAudioMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } Spacer(minLength: settings.outboundSpacer) @@ -99,13 +103,37 @@ public struct OutboundAudioMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - play() + messageContent() + .if(fileTuple?.url != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + CustomContextMenuAction(title: settings.save.title, + systemImage: settings.save.systemImage ?? "", tintColor: settings.save.color, flipped: nil, + attributes: nil) { + save() + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + play() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } } @@ -113,42 +141,6 @@ public struct OutboundAudioMessageRow: View { .padding(.bottom, message.actionType == .reply && message.relatedId.isEmpty == false ? 2 : settings.spacerBetweenRows) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(fileTuple?.url != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: settings.outboundAudioPreviewSize - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - CustomContextMenuAction(title: settings.save.title, - systemImage: settings.save.systemImage ?? "", tintColor: settings.save.color, flipped: nil, - attributes: nil) { - save() - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @@ -188,6 +180,9 @@ public struct OutboundAudioMessageRow: View { corners: features.forward.enable == true && message.actionType == .forward || message.actionType == .reply && message.relatedId.isEmpty == false ? settings.outboundForwardCorners : settings.outboundCorners) + .contentSize(onChange: { contentSize in + self.contentSize = contentSize + }) .padding(settings.outboundPadding) .padding(.leading, forPreview == true ? 24 : 0) diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundChatMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundChatMessageRow.swift index 2b5362e..cedb178 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundChatMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundChatMessageRow.swift @@ -54,7 +54,9 @@ public struct OutboundChatMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } Spacer(minLength: message.actionType == .reply && message.relatedId.isEmpty == false ? 100 : settings.outboundSpacer) @@ -95,51 +97,37 @@ public struct OutboundChatMessageRow: View { .contentSize(onChange: { contentSize in self.contentSize = contentSize }) - + .if(contentSize != nil, transform: { view in + view.customContextMenu ( + preview: MessageRowText(isOutbound: true, text: message.text) + .cornerRadius(settings.attachmentRadius, corners: settings.outboundForwardCorners), + preferredContentSize: size + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", + tintColor: settings.reply.color, + flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", + tintColor: settings.forward.color, + flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + DispatchQueue.main.async { + onSelect(message, .forward) + } + } + } + }) }.padding(settings.outboundPadding) } .padding(.bottom, message.actionType == .reply && message.relatedId.isEmpty == false ? 2 : settings.spacerBetweenRows) .fixedSize(horizontal: false, vertical: true) .id(message.id) - - .if(contentSize != nil, transform: { view in - view.customContextMenu ( - preview: MessageRowText(isOutbound: true, text: message.text) - .cornerRadius(settings.attachmentRadius, corners: settings.outboundForwardCorners), - preferredContentSize: size - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", - tintColor: settings.reply.color, - flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", - tintColor: settings.forward.color, - flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - DispatchQueue.main.async { - onSelect(message, .forward) - } - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundFileMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundFileMessageRow.swift index 9c5cb80..bc27dbe 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundFileMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundFileMessageRow.swift @@ -59,7 +59,9 @@ public struct OutboundFileMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } Spacer(minLength: settings.outboundSpacer) @@ -95,52 +97,38 @@ public struct OutboundFileMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if features.forward.enable == true, - messagesActionState == .forward { return } - if fileTuple?.url != nil { - open() + messageContent() + .if(contentSize != nil && fileTuple?.url != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } } } .padding(.bottom, message.actionType == .reply && message.relatedId.isEmpty == false ? 2 : settings.spacerBetweenRows) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(contentSize != nil && fileTuple?.url != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, - height: contentSize?.height ?? 0.0) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundGIFMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundGIFMessageRow.swift index 79376bf..c5422b7 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundGIFMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundGIFMessageRow.swift @@ -30,12 +30,19 @@ public struct OutboundGIFMessageRow: View { @State private var contentSize: CGSize? + private var preferredContentSize: CGSize { + guard let contentSize = contentSize else { + return .zero + } + return contentSize + } + public init(message: MessageItem, fileTuple: (type: String, image: UIImage?, url: URL?)? = nil, messagesActionState: MessageAction, relatedTime: Date?, relatedStatus: MessageStatus?, - + isSelected: Bool, onTap: @escaping (_ action: MessageAttachmentAction, _ url: URL?) -> Void, onSelect: @escaping (_ item: MessageItem, _ actionType: MessageAction) -> Void) { @@ -55,7 +62,9 @@ public struct OutboundGIFMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } Spacer(minLength: settings.outboundSpacer) @@ -91,103 +100,87 @@ public struct OutboundGIFMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if features.forward.enable == true, - messagesActionState == .forward { return } - if fileTuple?.url != nil { - open() + messageContent() + .if(contentSize != nil && fileTuple?.image != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: preferredContentSize + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } } } .padding(.bottom, message.actionType == .reply && message.relatedId.isEmpty == false ? 2 : settings.spacerBetweenRows) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(contentSize != nil && fileTuple?.image != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, - height: contentSize?.height ?? 0.0) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @ViewBuilder private func messageContent(forPreview: Bool = false) -> some View { - VStack(alignment: .leading, spacing: 0) { - - ZStack(alignment: .center) { - if let image = fileTuple?.image { - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(width: settings.attachmentSize(isPortrait: image.size.height > image.size.width).width, height: settings.attachmentSize(isPortrait: image.size.height > image.size.width).height) - .fixedSize() - .clipped() - .cornerRadius(settings.attachmentRadius, corners: features.forward.enable == true && message.actionType == .forward || - message.actionType == .reply && message.relatedId.isEmpty == false ? - settings.outboundForwardCorners : settings.outboundCorners) - .contentSize(onChange: { contentSize in - self.contentSize = contentSize - }) - .padding(settings.outboundPadding) - - - settings.videoPlayBackground - .frame(width: settings.imageIconSize.width, - height: settings.imageIconSize.height) - .cornerRadius(6) - - Text(settings.gifTitle) - .font(settings.gifFontPlay) - .foregroundColor(settings.videoPlayForeground) - - } else { - - settings.progressBarBackground() - - .frame(width: settings.attachmentSize.width, - height: settings.attachmentSize.height) - .cornerRadius(settings.attachmentRadius, corners: features.forward.enable == true && message.actionType == .forward || - message.actionType == .reply && message.relatedId.isEmpty == false ? - settings.outboundForwardCorners : settings.outboundCorners) - .contentSize(onChange: { contentSize in - self.contentSize = contentSize - }) - .padding(settings.outboundPadding) - - - SegmentedCircularBar(settings: settings.progressBar) - } + ZStack { + if let url = fileTuple?.url, url.pathExtension.lowercased() == "gif", + let image = UIImage.animatedImage(from: url) { + + MessageRowAnimatedImage(image: image) + .fixedSize() + .clipped() + .cornerRadius(settings.attachmentRadius, corners: features.forward.enable == true && message.actionType == .forward || + message.actionType == .reply && message.relatedId.isEmpty == false ? + settings.outboundForwardCorners : settings.outboundCorners) + .contentSize(onChange: { contentSize in + self.contentSize = contentSize + }) + .padding(settings.outboundPadding) + .padding(.leading, forPreview == true ? 8 : 0) + + } else if let image = fileTuple?.image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: settings.attachmentSize(isPortrait: image.size.height > image.size.width).width, height: settings.attachmentSize(isPortrait: image.size.height > image.size.width).height) + .fixedSize() + .clipped() + .padding(settings.outboundPadding) + .padding(.leading, forPreview == true ? 8 : 0) + + + settings.videoPlayBackground + .frame(width: settings.imageIconSize.width, + height: settings.imageIconSize.height) + .cornerRadius(6) + + Text(settings.gifTitle) + .font(settings.gifFontPlay) + .foregroundColor(settings.videoPlayForeground) + + } else { + + settings.progressBarBackground() + .frame(width: settings.attachmentSize.width, + height: settings.attachmentSize.height) + .padding(settings.outboundPadding) + .padding(.leading, forPreview == true ? 8 : 0) + + SegmentedCircularBar(settings: settings.progressBar) } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundImageMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundImageMessageRow.swift index c9cf502..8a125e2 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundImageMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundImageMessageRow.swift @@ -27,6 +27,8 @@ public struct OutboundImageMessageRow: View { private let onSelect: (_ item: MessageItem, _ actionType: MessageAction) -> Void + @State private var contentSize: CGSize? + public init(message: MessageItem, fileTuple: (type: String, image: UIImage?, url: URL?)? = nil, messagesActionState: MessageAction, @@ -51,7 +53,9 @@ public struct OutboundImageMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } Spacer(minLength: settings.outboundSpacer) @@ -88,54 +92,38 @@ public struct OutboundImageMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if features.forward.enable == true, - messagesActionState == .forward { return } - if fileTuple?.url != nil { - open() + messageContent() + .if(fileTuple?.image != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } } } .padding(.bottom, message.actionType == .reply && message.relatedId.isEmpty == false ? 2 : settings.spacerBetweenRows) .fixedSize(horizontal: false, vertical: true) .id(message.id) - - .if(fileTuple?.image != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: settings.attachmentSize.width, - height: settings.attachmentSize.height) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @@ -162,7 +150,7 @@ public struct OutboundImageMessageRow: View { } else { settings.progressBarBackground() .frame(width: settings.attachmentSize(isPortrait: true).width, height: settings.attachmentSize(isPortrait: true).height) - + SegmentedCircularBar(settings: settings.progressBar) } } @@ -171,6 +159,9 @@ public struct OutboundImageMessageRow: View { corners: features.forward.enable == true && message.actionType == .forward || message.actionType == .reply && message.relatedId.isEmpty == false ? settings.outboundForwardCorners : settings.outboundCorners) + .contentSize(onChange: { contentSize in + self.contentSize = contentSize + }) .padding(settings.outboundPadding) } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundVideoMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundVideoMessageRow.swift index 4d0710a..c276527 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundVideoMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/OutboundVideoMessageRow.swift @@ -26,6 +26,8 @@ public struct OutboundVideoMessageRow: View { private var relatedStatus: MessageStatus? = nil private var isSelected = false + @State private var contentSize: CGSize? + private let onSelect: (_ item: MessageItem, _ actionType: MessageAction) -> Void public init(message: MessageItem, @@ -52,7 +54,9 @@ public struct OutboundVideoMessageRow: View { if features.forward.enable == true, messagesActionState == .forward { - Checkbox(isSelected: isSelected) + Checkbox(isSelected: isSelected) { + onSelect(message, .forward) + } } Spacer(minLength: settings.outboundSpacer) @@ -89,51 +93,38 @@ public struct OutboundVideoMessageRow: View { messagesActionState == .forward { messageContent() } else { - Button { - if fileTuple?.url != nil { - open() + messageContent() + .if(fileTuple?.image != nil, transform: { view in + view.customContextMenu ( + preview: messageContent(forPreview: true), + preferredContentSize: CGSize(width: contentSize?.width ?? 0.0, + height: contentSize?.height ?? 0.0) + ) { + CustomContextMenuAction(title: settings.reply.title, + systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, + attributes: features.reply.enable == true + ? nil : .hidden) { + onSelect(message, .reply) + } + CustomContextMenuAction(title: settings.forward.title, + systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, + attributes: features.forward.enable == true + ? nil : .hidden) { + onSelect(message, .forward) + } + } + }) + .onTapGesture { + if fileTuple?.url != nil { + open() + } } - } label: { - messageContent() - }.buttonStyle(.plain) } } } .padding(.bottom, message.actionType == .reply && message.relatedId.isEmpty == false ? 2 : settings.spacerBetweenRows) .fixedSize(horizontal: false, vertical: true) .id(message.id) - .if(fileTuple?.image != nil, transform: { view in - view.customContextMenu ( - preview: messageContent(forPreview: true), - preferredContentSize: CGSize(width: settings.attachmentSize.width, - height: settings.attachmentSize.height) - ) { - CustomContextMenuAction(title: settings.reply.title, - systemImage: settings.reply.systemImage ?? "", tintColor: settings.reply.color, flipped: UIImageAxis.none, - attributes: features.reply.enable == true - ? nil : .hidden) { - onSelect(message, .reply) - } - CustomContextMenuAction(title: settings.forward.title, - systemImage: settings.forward.systemImage ?? "", tintColor: settings.forward.color, flipped: .horizontal, - attributes: features.forward.enable == true - ? nil : .hidden) { - onSelect(message, .forward) - } - } - }) - - if features.forward.enable == true, - messagesActionState == .forward { - Button { - onSelect(message, .forward) - } label: { - EmptyView() - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - } } } @@ -142,12 +133,12 @@ public struct OutboundVideoMessageRow: View { ZStack { if let image = fileTuple?.image { - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(width: settings.attachmentSize(isPortrait: image.size.height > image.size.width).width, height: settings.attachmentSize(isPortrait: image.size.height > image.size.width).height) - .fixedSize() - .clipped() + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: settings.attachmentSize(isPortrait: image.size.height > image.size.width).width, height: settings.attachmentSize(isPortrait: image.size.height > image.size.width).height) + .fixedSize() + .clipped() .cornerRadius(settings.attachmentRadius, corners: features.forward.enable == true && message.actionType == .forward || message.actionType == .reply && message.relatedId.isEmpty == false ? settings.outboundForwardCorners : settings.outboundCorners) @@ -182,6 +173,9 @@ public struct OutboundVideoMessageRow: View { SegmentedCircularBar(settings: settings.progressBar) } } + .contentSize(onChange: { contentSize in + self.contentSize = contentSize + }) } private func open() { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/RepliedMessageRow.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/RepliedMessageRow.swift index e6a4306..4c6d7a8 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/RepliedMessageRow.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/MessageRowView/RepliedMessageRow.swift @@ -122,7 +122,7 @@ public struct RepliedMessageRow: View { if type == .answerAssist { if features.ai.answerAssist.enable == true, features.ai.answerAssist.isValid == true, - let messageItem = repliedMessage as? Message { + let messageItem = item as? Message { viewModel.applyAIAnswerAssist(messageItem) } else { aiFeature = .answerAssist diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/PrivateDialogView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/PrivateDialogView.swift index 229e0ae..772ed07 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialog/PrivateDialogView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialog/PrivateDialogView.swift @@ -216,9 +216,7 @@ public struct PrivateDialogView: View { if let dialog = viewModel.dialog as? Dialog { switch dialog.type { case .private: - PrivateDialogInfoView(DialogInfoViewModel(dialog)).onAppear { - isInfoPresented = false - } + PrivateDialogInfoView(DialogInfoViewModel(dialog)) default: EmptyView() } @@ -226,7 +224,7 @@ public struct PrivateDialogView: View { } }) - .if(isIPad == true && isInfoPresented == true, transform: { view in + .if((isIPad == true || isMac == true) && isInfoPresented == true, transform: { view in view.sheet(isPresented: $isInfoPresented, content: { if let dialog = viewModel.dialog as? Dialog { switch dialog.type { @@ -248,13 +246,11 @@ public struct PrivateDialogView: View { ForwardView(viewModel: ForwardViewModel(messages: viewModel.selectedMessages as? [Message] ?? [])) { viewModel.cancelMessageAction() isForwardSuccess = true - }.onAppear { - isForwardPresented = false } } }) - .if(isForwardPresented == true && isIPad == true, transform: { view in + .if(isForwardPresented == true && (isIPad == true || isMac == true), transform: { view in view.sheet(isPresented: $isForwardPresented, content: { ForwardView(viewModel: ForwardViewModel(messages: viewModel.selectedMessages as? [Message] ?? [])) { viewModel.cancelMessageAction() @@ -352,9 +348,6 @@ public struct PrivateDialogView: View { } } }, onSelect: { item, actionType in - if viewModel.messagesActionState == actionType { - return - } viewModel.handleOnSelect(item, actionType: actionType) }, aiAnswerWaiting: $viewModel.waitingAnswer) .onAppear { @@ -385,7 +378,28 @@ public struct PrivateDialogView: View { } public var body: some View { - if isIPad { + if isIphone { + container() + .modifier(DialogHeader(dialog: viewModel.dialog, + isForward: viewModel.messagesActionState == .forward, + selectedCount: viewModel.selectedMessages.count, + onDismiss: { + dismiss() + }, + onTapInfo: { + isInfoPresented = true + }, onTapCancel: { + viewModel.cancelMessageAction() + })) + .onViewDidLoad { + viewModel.sync() + } + .onDisappear { + viewModel.sendStopTyping() + viewModel.stopPlayng() + viewModel.unsync() + } + } else { NavigationStack { container() .modifier(DialogHeader(dialog: viewModel.dialog, @@ -409,27 +423,6 @@ public struct PrivateDialogView: View { } } - } else if isIphone { - container() - .modifier(DialogHeader(dialog: viewModel.dialog, - isForward: viewModel.messagesActionState == .forward, - selectedCount: viewModel.selectedMessages.count, - onDismiss: { - dismiss() - }, - onTapInfo: { - isInfoPresented = true - }, onTapCancel: { - viewModel.cancelMessageAction() - })) - .onViewDidLoad { - viewModel.sync() - } - .onDisappear { - viewModel.sendStopTyping() - viewModel.stopPlayng() - viewModel.unsync() - } } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/CreateDialog/CreateDialogView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/CreateDialog/CreateDialogView.swift index e03e237..e0b6314 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/CreateDialog/CreateDialogView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/CreateDialog/CreateDialogView.swift @@ -31,7 +31,7 @@ where DialogItem == ViewModel.DialogItem, UserItem == ViewModel.UserItem { .onViewDidLoad { viewModel.syncUsers() } - } else if isIPad { + } else { NavigationStack { container() .onViewDidLoad { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogListViewModifier/DialogListHeader.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogListViewModifier/DialogListHeader.swift index 789aa4a..19d51c3 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogListViewModifier/DialogListHeader.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogListViewModifier/DialogListHeader.swift @@ -113,6 +113,10 @@ public var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad } +public var isMac: Bool { + UIDevice.current.userInterfaceIdiom == .mac +} + public struct DeleteDialogAlert: ViewModifier { public var settings = QuickBloxUIKit.settings.membersScreen.removeUser @@ -231,48 +235,45 @@ struct EmptyDialogView: View { } public extension View { - func addKeyboardVisibilityToEnvironment() -> some View { - modifier(KeyboardVisibility()) + func addSearchBar( + isSearchable: Bool, + searchText: Binding, + placeholder: String, + submittedSearchTerm: Binding + ) -> some View { + self.modifier(AddSearchBar( + isSearchable: isSearchable, + searchText: searchText, + placeholder: placeholder, + submittedSearchTerm: submittedSearchTerm + )) } } -private struct KeyboardShowingEnvironmentKey: EnvironmentKey { - static let defaultValue: Bool = false -} +public struct AddSearchBar: ViewModifier { + let isSearchable: Bool + @Binding var searchText: String + let placeholder: String + @Binding var submittedSearchTerm: String -extension EnvironmentValues { - var keyboardShowing: Bool { - get { self[KeyboardShowingEnvironmentKey.self] } - set { self[KeyboardShowingEnvironmentKey.self] = newValue } - } -} + public func body(content: Content) -> some View { + guard isSearchable else { + return AnyView(content) + } -import Combine -private struct KeyboardVisibility:ViewModifier { - - @State var isKeyboardShowing:Bool = false - - private var keyboardPublisher: AnyPublisher { - Publishers - .Merge( - NotificationCenter - .default - .publisher(for: UIResponder.keyboardWillShowNotification) - .map { _ in true }, - NotificationCenter - .default - .publisher(for: UIResponder.keyboardWillHideNotification) - .map { _ in false }) - .debounce(for: .seconds(0.1), scheduler: RunLoop.main) - .eraseToAnyPublisher() - } - - fileprivate func body(content: Content) -> some View { - content - .environment(\.keyboardShowing, isKeyboardShowing) - .onReceive(keyboardPublisher) { value in - isKeyboardShowing = value + return AnyView( + content + .searchable(text: $searchText, prompt: placeholder) + .onSubmit(of: .search) { + submittedSearchTerm = searchText } + .onChange(of: searchText) { value in + if searchText.isEmpty { + submittedSearchTerm = "" + } + } + .autocorrectionDisabled(true) + ) } - } + diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogRowView/DialogRowView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogRowView/DialogRowView.swift index 8f7b882..0e68f41 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogRowView/DialogRowView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogRowView/DialogRowView.swift @@ -78,7 +78,7 @@ extension DialogRowView { isHidden: settings.isHiddenAvatar)) VStack(spacing: settings.infoSpacing) { HStack { - nameView ?? DialogRowName(text: dialog.validName) + nameView ?? DialogRowName(text: dialog.name) settings.infoSpacer timeView ?? DialogRowTime(time: dialog.time, isHidden: settings.isHiddenTime) @@ -211,7 +211,7 @@ public struct SelectDialogRowView: View { height: settings.selectAvatarSize.height, isHidden: settings.isHiddenAvatar ) - UserRowName(text: dialog.validName) + UserRowName(text: dialog.name) Spacer() Checkbox(isSelected: isSelected) { @@ -260,14 +260,4 @@ extension DialogEntity { } return formatter.string(from: date) } - - public var validName: String { - let settings = QuickBloxUIKit.settings.dialogScreen.messageRow.name - let regex = QuickBloxUIKit.feature.regex - - if type == .private && regex.userName.isEmpty == false { - return name.isValid(regexes: [regex.userName]) == true ? name : settings.unknown - } - return name - } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogTypeView/DialogTypeView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogTypeView/DialogTypeView.swift index 91fb8cb..53dfa4d 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogTypeView/DialogTypeView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogTypeView/DialogTypeView.swift @@ -29,7 +29,7 @@ struct DialogTypeView: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) @@ -62,7 +62,7 @@ struct DialogTypeView: View { } } - .if(presentCreateDialog == true && isIPad == true) { view in + .if(presentCreateDialog == true && (isIPad == true || isMac == true)) { view in view.sheet(isPresented: $presentCreateDialog, content: { if let selectedSegment { if selectedSegment == .private { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogsView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogsView.swift index 66298df..e3cc73e 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogsView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/DialogsView.swift @@ -15,9 +15,7 @@ public struct DialogsView: View { let settings = QuickBloxUIKit.settings.dialogsScreen let feature = QuickBloxUIKit.feature - @Environment(\.keyboardShowing) var keyboardShowing @Environment(\.dismiss) var dismiss - @Environment(\.isSearching) var isSearching let connectStatus = QuickBloxUIKit.settings.dialogsScreen.connectStatus @@ -57,83 +55,40 @@ public struct DialogsView: View { @ViewBuilder private func container() -> some View { - ZStack(alignment: .center) { - settings.backgroundColor.ignoresSafeArea() - HStack { - if isIPad { + if isDialogTypePresented == true { + DialogTypeView(onClose: { + // action onDismiss + isDialogTypePresented = false + }) + } else if feature.toolbar.enable { + TabView(selection: $selectedSegment) { + if (isIPad == true || isMac == true) { Spacer() } + dialogsContentView().blur(radius: isDialogTypePresented ? settings.blurRadius : 0) + .toolbarBackground(settings.backgroundColor, for: .tabBar) + .toolbarBackground(tabBarVisibility, for: .tabBar) + .tabItem { + Label(TabIndex.dialogs.title, systemImage: TabIndex.dialogs.systemIcon) + } + .tag(TabIndex.dialogs) - if feature.toolbar.enable { - TabView(selection: $selectedSegment) { - dialogsContentView().blur(radius: isDialogTypePresented ? settings.blurRadius : 0) - .toolbar(tabBarVisibility, for: .tabBar) - .toolbarBackground(settings.backgroundColor, for: .tabBar) - .toolbarBackground(tabBarVisibility, for: .tabBar) - .tag(TabIndex.dialogs) - .tabItem { - Label(TabIndex.dialogs.title, systemImage: TabIndex.dialogs.systemIcon) - } - - ForEach(feature.toolbar.externalIndexes, id:\.self) { tabIndex in - settings.backgroundColor.ignoresSafeArea() - .toolbar(tabBarVisibility, for: .tabBar) - .toolbarBackground(settings.backgroundColor, for: .tabBar) - .toolbarBackground(tabBarVisibility, for: .tabBar) - .tabItem { - Label(tabIndex.title, systemImage: tabIndex.systemIcon) - } - .tag(tabIndex) + ForEach(feature.toolbar.externalIndexes, id:\.self) { tabIndex in + settings.backgroundColor.ignoresSafeArea() + .toolbarBackground(settings.backgroundColor, for: .tabBar) + .toolbarBackground(tabBarVisibility, for: .tabBar) + .tabItem { + Label(tabIndex.title, systemImage: tabIndex.systemIcon) } - } - .accentColor(settings.header.rightButton.color) - } else { - dialogsContentView().blur(radius: isDialogTypePresented ? settings.blurRadius : 0) - } - } - - if isDialogTypePresented == true { - DialogTypeView(onClose: { - // action onDismiss - isDialogTypePresented = false - }) - } - } - .addKeyboardVisibilityToEnvironment() - - .if(settings.searchBar.isSearchable, - transform: { view in - view.searchable(text: $searchText, - prompt: settings.searchBar.searchTextField.placeholderText) - .onSubmit(of: .search) { - submittedSearchTerm = searchText - }.onChange(of: searchText) { value in - if searchText.isEmpty && !isSearching { - submittedSearchTerm = "" + .tag(tabIndex) } } - .autocorrectionDisabled(true) - }) - - .onAppear { - selectedSegment = .dialogs - } - - .onChange(of: selectedSegment, perform: { newSelectedSegment in - if newSelectedSegment != .dialogs { + .onChange(of: selectedSegment, perform: { newSelectedSegment in onSelect(newSelectedSegment) - } - }) - - .onChange(of: dialogsList.selectedItem, perform: { newSelectedItem in - isDialogTypePresented = false - isPresentedItem = newSelectedItem != nil - }) - - .if(dialogsList.dialogToBeDeleted != nil) { view in - view.overlay() { - CustomProgressView() - } + }) + .accentColor(settings.header.rightButton.color) + } else { + dialogsContentView().blur(radius: isDialogTypePresented ? settings.blurRadius : 0) } } @@ -170,6 +125,18 @@ public struct DialogsView: View { } dialogForDeleting = nil }) + + .addSearchBar( + isSearchable: settings.searchBar.isSearchable, + searchText: $searchText, + placeholder: settings.searchBar.searchTextField.placeholderText, + submittedSearchTerm: $submittedSearchTerm + ) + .if(dialogsList.dialogToBeDeleted != nil) { view in + view.overlay() { + CustomProgressView() + } + } } @ViewBuilder @@ -181,12 +148,13 @@ public struct DialogsView: View { } else { List(items) { item in Button { - if isIPad && dialogsList.selectedItem?.id == item.id { + if (isIPad == true || isMac == true) && dialogsList.selectedItem?.id == item.id { return } dialogsList.selectedItem = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { dialogsList.selectedItem = item + isPresentedItem = true } } label: { DialogsRowBuilder.defaultRow(item) @@ -219,6 +187,10 @@ public struct DialogsView: View { if isIphone { NavigationStack { container() + .onChange(of: dialogsList.selectedItem, perform: { newSelectedItem in + isDialogTypePresented = false + isPresentedItem = newSelectedItem != nil + }) .navigationDestination(isPresented: $isPresentedItem) { if let dialog = dialogsList.selectedItem as? Dialog { switch dialog.type { @@ -238,21 +210,23 @@ public struct DialogsView: View { isDialogTypePresented = true })) .navigationBarHidden(isDialogTypePresented) - }.accentColor(settings.header.leftButton.color) + } + .accentColor(settings.header.leftButton.color) } - else if isIPad { - + else { NavigationSplitView(columnVisibility: Binding.constant(.all)) { - NavigationStack { - container() - .modifier(DialogListHeader(onDismiss: { - onBack() - dismiss() - }, onTapDialogType: { - isDialogTypePresented = true - })) - .navigationBarHidden(isDialogTypePresented) - } + container() + .onChange(of: dialogsList.selectedItem, perform: { newSelectedItem in + isDialogTypePresented = false + isPresentedItem = newSelectedItem != nil + }) + .modifier(DialogListHeader(onDismiss: { + onBack() + dismiss() + }, onTapDialogType: { + isDialogTypePresented = true + })) + .navigationBarHidden(isDialogTypePresented) } detail: { if let dialog = dialogsList.selectedItem as? Dialog { switch dialog.type { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/DialogNameViewModifier.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/DialogNameViewModifier.swift index 2dd0ff6..e912f5b 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/DialogNameViewModifier.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/DialogNameViewModifier.swift @@ -179,7 +179,7 @@ public struct CustomMediaAlert: ViewModifier { }) }) - .if(isIPad == true && isAlertPresented == true, transform: { view in + .if((isIPad == true || isMac == true) && isAlertPresented == true, transform: { view in ZStack { view.disabled(true) .overlay( diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/NewDialog.swift b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/NewDialog.swift index 35262cc..d6cd378 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/NewDialog.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Dialogs/NewDialog/NewDialog.swift @@ -39,7 +39,7 @@ struct NewDialog: View { public var body: some View { if isIphone { container() - } else if isIPad { + } else { NavigationStack { container() }.accentColor(settings.header.leftButton.color) @@ -111,9 +111,6 @@ struct NewDialog: View { CreateDialogView(viewModel: CreateDialogViewModel(modeldDialog: Dialog(type: modelDialog.type, name: modelDialog.name, photo: modelDialog.photo))) - .onAppear { - isCreatedDialog = false - } } } } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/AIFeatureSettings.swift b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/AIFeatureSettings.swift index 8fcb485..78df2d9 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/AIFeatureSettings.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/AIFeatureSettings.swift @@ -7,6 +7,7 @@ // import SwiftUI +import Quickblox import QBAITranslate import QBAIRephrase import QBAIAnswerAssistant @@ -15,6 +16,18 @@ public class AIFeature { /// Determines if AIfunctionality is enabled. public var enable: Bool = true + /// The OpenAI API key for [Quickblox Server REST API] (https://docs.quickblox.com/docs/ai-extensions). + public var smartChatAssistantId = "" { + didSet { + if answerAssist.smartChatAssistantId.isEmpty { + answerAssist.smartChatAssistantId = smartChatAssistantId + } + if translate.smartChatAssistantId.isEmpty { + translate.smartChatAssistantId = smartChatAssistantId + } + } + } + /// The OpenAI API key for direct API requests (if not using a proxy server). public var apiKey = "" { didSet { @@ -49,12 +62,14 @@ public class AIFeature { /// Settings for assist answer functionality using the OpenAI API key or QuickBlox token and proxy server. public var answerAssist: AIAnswerAssistSettings = AIAnswerAssistSettings(enable: true, + smartChatAssistantId: "", apiKey: "", serverPath: "") /// Settings for translation functionality using the OpenAI API key or QuickBlox token and proxy server. public var translate: AITranslateSettings = AITranslateSettings(enable: true, + smartChatAssistantId: "", apiKey: "", serverPath: "") @@ -72,6 +87,9 @@ public class AIAnswerAssistSettings { /// Determines if assist answer functionality is enabled. public var enable: Bool = true + /// The OpenAI API key for [Quickblox Server REST API] (https://docs.quickblox.com/reference/ai-extensions-ai-answer-assist). + public var smartChatAssistantId: String = "" + /// The OpenAI API key for direct API requests (if not using a proxy server). public var apiKey: String = "" @@ -97,25 +115,85 @@ public class AIAnswerAssistSettings { /// The maximum number of tokens to generate in the response. public var maxResponseTokens: Int? = nil - /// Indicates if the AI settings are valid, i.e., either the OpenAI API key or proxy server URL is provided. + /// Indicates if the AI settings are valid, i.e., either the OpenAI API key Quickblox Server REST API . public var isValid: Bool { - return apiKey.isEmpty == false || serverPath.isEmpty == false + return smartChatAssistantId.isEmpty == false || + apiKey.isEmpty == false || serverPath.isEmpty == false } /// Initializes the AIAnswerAssistSettings with the given values. /// - Parameters: /// - enable: Determines if assist answer functionality is enabled. - /// - apiKey: The OpenAI API key for direct API requests (if not using a proxy server). - /// - serverPath: The URL path of the proxy server for more secure communication (if not using the API key directly). - required public init(enable: Bool, apiKey: String, serverPath: String) { + required public init(enable: Bool, + smartChatAssistantId: String, + apiKey: String, + serverPath: String) { self.enable = enable + self.smartChatAssistantId = smartChatAssistantId self.apiKey = apiKey self.serverPath = serverPath } } +public extension Locale { + static var defaultLocalizedLanguageName = "English" + + /// Returns the localized language name for the locale. + var localizedLanguageName: String { + guard let code = self.language.languageCode?.identifier else { + return Locale.defaultLocalizedLanguageName + } + + let english = QBAILanguage.english + return english.locale.localizedString(forLanguageCode: code) + ?? Locale.defaultLocalizedLanguageName + } +} + +extension QBAILanguage: CaseIterable { + /// Returns the `Locale` associated with the language. + public var locale: Locale { + return Locale(identifier: rawValue) + } + + public static var allCases: [QBAILanguage] { + [english, spanish, chineseSimplified, chineseTraditional, french, german, japanese, korean, + italian, russian, portuguese, arabic, hindi, turkish, dutch, polish, ukrainian, albanian, + armenian, azerbaijani, basque, belarusian, bengali, bosnian, bulgarian, catalan, croatian, + czech, danish, estonian, finnish, galician, georgian, greek, gujarati, hungarian, + indonesian, irish, kannada, kazakh, latvian, lithuanian, macedonian, malay, maltese, + mongolian, nepali, norwegian, pashto, persian, punjabi, romanian, sanskrit, serbian, + sindhi, sinhala, slovak, slovenian, uzbek, vietnamese, welsh] + } +} + /// Settings for translation functionality. public class AITranslateSettings { + private var _aiLanguage: QBAILanguage? = nil + + /// The current `QBAILanguage`. + /// + /// Default the same as system language or `.english` if `QBAILanguage` is not support system language. + public var aiLanguage: QBAILanguage + { + get { + guard let value = _aiLanguage else { + let currentName = Locale.current.localizedLanguageName + for language in QBAILanguage.allCases { + if language.locale.localizedLanguageName == currentName { + return language + } + } + + return QBAILanguage.english + } + + return value + } set { + _aiLanguage = newValue + } + } + private var _language: QBAITranslate.Language? = nil /// The current `QBAITranslate.Language`. @@ -143,6 +221,9 @@ public class AITranslateSettings { /// Determines if translation functionality is enabled. public var enable: Bool = true + /// The OpenAI API key for [Quickblox Server REST API] (https://docs.quickblox.com/reference/ai-extensions-ai-translate). + public var smartChatAssistantId: String = "" + /// The OpenAI API key for direct API requests (if not using a proxy server). public var apiKey: String = "" @@ -168,18 +249,21 @@ public class AITranslateSettings { /// The maximum number of tokens to generate in the response. public var maxResponseTokens: Int? = nil - /// Indicates if the AI settings are valid, i.e., either the OpenAI API key or proxy server URL is provided. + /// Indicates if the AI settings are valid, i.e., either the OpenAI API key Quickblox Server REST API . public var isValid: Bool { - return apiKey.isEmpty == false || serverPath.isEmpty == false + return smartChatAssistantId.isEmpty == false || + apiKey.isEmpty == false || serverPath.isEmpty == false } /// Initializes the AITranslateSettings with the given values. /// - Parameters: /// - enable: Determines if assist answer functionality is enabled. - /// - apiKey: The OpenAI API key for direct API requests (if not using a proxy server). - /// - serverPath: The URL path of the proxy server for more secure communication (if not using the API key directly). - required public init(enable: Bool, apiKey: String, serverPath: String) { + required public init(enable: Bool, + smartChatAssistantId: String, + apiKey: String, + serverPath: String) { self.enable = enable + self.smartChatAssistantId = smartChatAssistantId self.apiKey = apiKey self.serverPath = serverPath } diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/DialogInfoScreenSettings.swift b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/DialogInfoScreenSettings.swift index c3c429e..7456ece 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/DialogInfoScreenSettings.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/DialogInfoScreenSettings.swift @@ -57,7 +57,6 @@ public class DialogInfoScreenSettings { public var color: Color public var font: Font public var height: CGFloat = 80.0 - public var containerHeight: CGFloat = 110.0 public var isHidden: Bool = false public var padding: EdgeInsets = EdgeInsets(top: 24.0, leading: 0.0, diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/Feature.swift b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/Feature.swift index 3bcb1fa..8165f3d 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/Feature.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/Feature.swift @@ -7,9 +7,6 @@ // import SwiftUI -import QBAITranslate -import QBAIRephrase -import QBAIAnswerAssistant public class Feature { /// An instance of the AI module for AI-related settings and operations. diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/FeatureSettings.swift b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/FeatureSettings.swift index 2cfca32..07c01cf 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/FeatureSettings.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Theme/Settings/FeatureSettings.swift @@ -38,8 +38,8 @@ public class RegexFeature { } public class ToolbarFeature { - public var enable: Bool = true - public var externalIndexes: [TabIndex] = [.settings] + public var enable: Bool = false + public var externalIndexes: [TabIndex] = [] } public struct ToolbarUISettings { diff --git a/Sources/QuickBloxUIKit/SwiftUIView/Users/UserRowView/UserRowView.swift b/Sources/QuickBloxUIKit/SwiftUIView/Users/UserRowView/UserRowView.swift index 4459ffc..c76aca8 100644 --- a/Sources/QuickBloxUIKit/SwiftUIView/Users/UserRowView/UserRowView.swift +++ b/Sources/QuickBloxUIKit/SwiftUIView/Users/UserRowView/UserRowView.swift @@ -71,7 +71,7 @@ public struct UserRow: View { height: settings.contentHeight, isHidden: settings.isHiddenAvatar ) - nameView ?? UserRowName(text: user.validName) + nameView ?? UserRowName(text: user.name) Spacer() checkboxView ?? Checkbox(isSelected: isSelected, onTap: { onTap(user) @@ -126,7 +126,7 @@ public struct RemoveUserRow: View { height: settings.contentHeight, isHidden: settings.isHiddenAvatar ) - nameView ?? UserRowName(text: user.validName) + nameView ?? UserRowName(text: user.name) Spacer() if user.id == ownerId { RoleUserRowName().padding(.trailing, 60) @@ -150,19 +150,6 @@ public struct RemoveUserRow: View { } } -private extension UserEntity { - var validName: String { - let settings = QuickBloxUIKit.settings.createDialogScreen.userRow.name - let regex = QuickBloxUIKit.feature.regex - - var valid = regex.userName.isEmpty ? name : (name.isValid(regexes: [regex.userName]) == true ? name : settings.unknown) - if isCurrent { - valid = valid + settings.you - } - return valid - } -} - public struct AddUserRow: View { @State public var avatar: Image = QuickBloxUIKit.settings.createDialogScreen.userRow.avatar @@ -194,7 +181,7 @@ public struct AddUserRow: View { height: settings.contentHeight, isHidden: settings.isHiddenAvatar ) - nameView ?? UserRowName(text: user.validName) + nameView ?? UserRowName(text: user.name) Spacer() addBoxView ?? AddUserButton(onTap: { onTap(user) diff --git a/Sources/QuickBloxUIKit/ViewModel/DialogViewModel.swift b/Sources/QuickBloxUIKit/ViewModel/DialogViewModel.swift index f52e59b..383115b 100644 --- a/Sources/QuickBloxUIKit/ViewModel/DialogViewModel.swift +++ b/Sources/QuickBloxUIKit/ViewModel/DialogViewModel.swift @@ -10,6 +10,7 @@ import UIKit import SwiftUI import QuickBloxDomain import QuickBloxData +import Quickblox import Combine import Photos import QuickBloxLog @@ -591,9 +592,6 @@ open class DialogViewModel: DialogViewModelProtocol { } private func didSelect(single: Bool, item: DialogItem.MessageItem) { - if single, selectedMessages.isEmpty == false { - return - } if selectedMessages.contains(where: { $0.id == item.id && $0.relatedId == item.relatedId}) == true, single == false { selectedMessages.removeAll(where: { $0.id == item.id && $0.relatedId == item.relatedId }) @@ -615,45 +613,59 @@ open class DialogViewModel: DialogViewModelProtocol { //MARK: - AI Features public func applyAIAnswerAssist(_ message: DialogItem.MessageItem) { + guard QuickBloxUIKit.feature.ai.answerAssist.isValid else { return } waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: true) aiAnswer = "" - let messages = filterTextHistory(from: message.date) + var messages = filterTextHistory(from: message) let answerAssist = QuickBloxUIKit.feature.ai.answerAssist - var settings: QBAIAnswerAssistant.AISettings? + var useCase: AIFeatureUseCaseProtocol! - if answerAssist.serverPath.isEmpty == false { + if answerAssist.smartChatAssistantId.isEmpty == false { + useCase = AIAnswerAssist(message: message, + history: messages, + smartChatAssistantId: answerAssist.smartChatAssistantId, + repo: RepositoriesFabric.ai) + } else { + var settings: QBAIAnswerAssistant.AISettings? - settings = QBAIAnswerAssistant.AISettings(token: "", - serverPath: answerAssist.serverPath, - apiVersion: answerAssist.apiVersion, - organization: answerAssist.organization, - model: answerAssist.model, - temperature: answerAssist.temperature, - maxRequestTokens: answerAssist.maxRequestTokens, - maxResponseTokens: answerAssist.maxResponseTokens) - } else if answerAssist.apiKey.isEmpty == false { + if answerAssist.serverPath.isEmpty == false { + + settings = QBAIAnswerAssistant.AISettings(token: "", + serverPath: answerAssist.serverPath, + apiVersion: answerAssist.apiVersion, + organization: answerAssist.organization, + model: answerAssist.model, + temperature: answerAssist.temperature, + maxRequestTokens: answerAssist.maxRequestTokens, + maxResponseTokens: answerAssist.maxResponseTokens) + } else if answerAssist.apiKey.isEmpty == false { + + settings = QBAIAnswerAssistant.AISettings(apiKey: answerAssist.apiKey, + apiVersion: answerAssist.apiVersion, + organization: answerAssist.organization, + model: answerAssist.model, + temperature: answerAssist.temperature, + maxRequestTokens: answerAssist.maxRequestTokens, + maxResponseTokens: answerAssist.maxResponseTokens) + } - settings = QBAIAnswerAssistant.AISettings(apiKey: answerAssist.apiKey, - apiVersion: answerAssist.apiVersion, - organization: answerAssist.organization, - model: answerAssist.model, - temperature: answerAssist.temperature, - maxRequestTokens: answerAssist.maxRequestTokens, - maxResponseTokens: answerAssist.maxResponseTokens) - } - - guard let settings = settings else { - waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: false) - self.aiAnswer = message.text - return + guard let settings = settings else { + waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: false) + self.aiAnswer = message.text + return + } + + messages.append(message) + + print("messages = \(messages)") + + useCase = AnswerAssist(messages, settings: settings) } - let useCase = AnswerAssist(messages, settings: settings) - Task { [weak self] in do { let answer = try await useCase.execute() @@ -676,45 +688,55 @@ open class DialogViewModel: DialogViewModelProtocol { } public func applyAITranslate(_ message: DialogItem.MessageItem) { - waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: true) - - let messages = filterTextHistory(from: message.date) - + guard QuickBloxUIKit.feature.ai.translate.isValid else { return } let translateSettings = QuickBloxUIKit.feature.ai.translate - var settings: QBAITranslate.AISettings? + waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: true) + + var useCase: AIFeatureUseCaseProtocol! - if translateSettings.serverPath.isEmpty == false { + if translateSettings.smartChatAssistantId.isEmpty == false { + useCase = AITranslate(message.text, + smartChatAssistantId: translateSettings.smartChatAssistantId, + languageCode: translateSettings.aiLanguage.rawValue, + repo: RepositoriesFabric.ai) + } else { + var settings: QBAITranslate.AISettings? - settings = QBAITranslate.AISettings(token: "", - serverPath: translateSettings.serverPath, - language: translateSettings.language, - apiVersion: translateSettings.apiVersion, - organization: translateSettings.organization, - model: translateSettings.model, - temperature: translateSettings.temperature, - maxRequestTokens: translateSettings.maxRequestTokens, - maxResponseTokens: translateSettings.maxResponseTokens) - } else if translateSettings.apiKey.isEmpty == false { + if translateSettings.serverPath.isEmpty == false { + + settings = QBAITranslate.AISettings(token: "", + serverPath: translateSettings.serverPath, + language: translateSettings.language, + apiVersion: translateSettings.apiVersion, + organization: translateSettings.organization, + model: translateSettings.model, + temperature: translateSettings.temperature, + maxRequestTokens: translateSettings.maxRequestTokens, + maxResponseTokens: translateSettings.maxResponseTokens) + } else if translateSettings.apiKey.isEmpty == false { + + settings = QBAITranslate.AISettings(apiKey: translateSettings.apiKey, + language: translateSettings.language, + apiVersion: translateSettings.apiVersion, + organization: translateSettings.organization, + model: translateSettings.model, + temperature: translateSettings.temperature, + maxRequestTokens: translateSettings.maxRequestTokens, + maxResponseTokens: translateSettings.maxResponseTokens) + } - settings = QBAITranslate.AISettings(apiKey: translateSettings.apiKey, - language: translateSettings.language, - apiVersion: translateSettings.apiVersion, - organization: translateSettings.organization, - model: translateSettings.model, - temperature: translateSettings.temperature, - maxRequestTokens: translateSettings.maxRequestTokens, - maxResponseTokens: translateSettings.maxResponseTokens) - } - - guard let settings = settings else { - waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: false) - self.aiAnswer = message.text - return + guard let settings = settings else { + waitingAnswer = AIAnswerInfo(id: message.id, relatedId: message.relatedId, waiting: false) + self.aiAnswer = message.text + return + } + + let messages = filterTextHistory(from: message.date) + + useCase = Translate(message.text, content: messages, settings: settings) } - let useCase = Translate(message.text, content: messages, settings: settings) - Task { [weak self] in do { let translatedText = try await useCase.execute() @@ -851,7 +873,7 @@ open class DialogViewModel: DialogViewModelProtocol { private func filterTextHistory(from date: Date) -> [QuickBloxData.Message] { let messages = dialog.messages.filter { message in - if message.isText == true, message.date <= date { + if message.isText == true, message.date < date { return true } return false @@ -859,6 +881,18 @@ open class DialogViewModel: DialogViewModelProtocol { return messages } + private func filterTextHistory(from question: QuickBloxData.Message) -> [QuickBloxData.Message] { + let messages = dialog.messages.filter { message in + if message.isText == true, + message.date < question.date, + message.isOwnedByCurrentUser || message.userId == question.userId { + return true + } + return false + } + return messages + } + //MARK: - Media Permissions public func requestPermission(_ mediaType: AVMediaType, completion: @escaping (_ granted: Bool) -> Void) { switch mediaType { diff --git a/Sources/QuickBloxUIKit/ViewModel/DialogsViewModel.swift b/Sources/QuickBloxUIKit/ViewModel/DialogsViewModel.swift index fe89307..84ca25a 100644 --- a/Sources/QuickBloxUIKit/ViewModel/DialogsViewModel.swift +++ b/Sources/QuickBloxUIKit/ViewModel/DialogsViewModel.swift @@ -54,7 +54,7 @@ final class DialogsViewModel: DialogsListProtocol { createDialogObserve.execute() .receive(on: RunLoop.main) .sink { [weak self] dialog in - if isIPad { + if (isIPad == true || isMac == true) { self?.selectedItem = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.async { @@ -73,7 +73,7 @@ final class DialogsViewModel: DialogsListProtocol { .receive(on: RunLoop.main) .sink { [weak self] dialogId in if dialogId == self?.selectedItem?.id { - if isIPad { + if (isIPad == true || isMac == true) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self?.selectedItem = nil } diff --git a/Sources/QuickBloxUIKit/ViewModel/Utils/Utils.swift b/Sources/QuickBloxUIKit/ViewModel/Utils/Utils.swift index d07d2d4..a4820cb 100644 --- a/Sources/QuickBloxUIKit/ViewModel/Utils/Utils.swift +++ b/Sources/QuickBloxUIKit/ViewModel/Utils/Utils.swift @@ -341,15 +341,14 @@ extension MessageEntity { return (uploaded.info.name, cachedImage, localURL) } var image: UIImage? - if let uiImage = await localURL.getThumbnailImage() { - if size != nil { - let imageSize = settings.imageSize(isPortrait: uiImage.size.height > uiImage.size.width) - let resized = uiImage.crop(to: imageSize) - imageCache.store(resized, for: id) - image = resized - } else { - image = uiImage - } + let uiImage = try await localURL.getThumbnailImage() + if size != nil { + let imageSize = settings.imageSize(isPortrait: uiImage.size.height > uiImage.size.width) + let resized = uiImage.crop(to: imageSize) + imageCache.store(resized, for: id) + image = resized + } else { + image = uiImage } return (uploaded.info.name, image, localURL) case .image: @@ -514,16 +513,19 @@ extension File { } extension URL { - func getThumbnailImage() async -> UIImage? { - return await withCheckedContinuation({ - (continuation: CheckedContinuation) in - self.getThumbnailImage { image in + func getThumbnailImage() async throws -> UIImage { + return try await withCheckedThrowingContinuation { continuation in + self.getThumbnailImage { image, error in + guard let image = image else { + continuation.resume(throwing: error ?? RepositoryException.incorrectData("Thumbnail Image")) + return + } continuation.resume(returning: image) } - }) + } } - func getThumbnailImage(completion: @escaping ((_ image: UIImage?) -> Void)) { + func getThumbnailImage(completion: @escaping ((_ image: UIImage?, _ error: Error?) -> Void)) { if let document = CGPDFDocument(self as CFURL), let page = document.page(at: 1) { let pageRect = page.getBoxRect(.mediaBox) @@ -536,10 +538,24 @@ extension URL { ctx.cgContext.scaleBy(x: 1.0, y: -1.0) ctx.cgContext.drawPDFPage(page) } - completion(image) + completion(image, nil) return } DispatchQueue.global().async { + if self.pathExtension.lowercased() == "gif" { + guard let imageData = try? Data(contentsOf: self), + let image = UIImage(data: imageData) else { + DispatchQueue.main.async { + completion(nil, NSError(domain: "InvalidGIF", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unable to load GIF"])) + } + return + } + DispatchQueue.main.async { + completion(image, nil) + } + return + } + let avAsset = AVAsset(url: self) let avAssetImageGenerator = AVAssetImageGenerator(asset: avAsset) avAssetImageGenerator.appliesPreferredTrackTransform = true @@ -548,12 +564,12 @@ extension URL { let cgThumbImage = try avAssetImageGenerator.copyCGImage(at: thumnailTime, actualTime: nil) let thumbImage = UIImage(cgImage: cgThumbImage) DispatchQueue.main.async { - completion(thumbImage) + completion(thumbImage, nil) } } catch { prettyLog(error) DispatchQueue.main.async { - completion(nil) + completion(nil, error) } } } @@ -697,4 +713,51 @@ extension UIImage { return resizedImage } + + static func animatedImage(from url: URL) -> UIImage? { + guard let data = try? Data(contentsOf: url) else { return nil } + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + + var images: [UIImage] = [] + let count = CGImageSourceGetCount(source) + + var scaleFactor = 1.0 + + for i in 0.. imageSize.width + + // Define the target size (portrait or landscape) + let targetSize = isPortrait ? CGSize(width: 160.0, height: 240.0) : CGSize(width: 240.0, height: 160.0) + + // Check if the image's size exceeds the target size + if imageSize.width > targetSize.width || imageSize.height > targetSize.height { + // Calculate the scaling factor to fit the image within the target size while preserving the aspect ratio + let scaleFactor = min(targetSize.width / imageSize.width, targetSize.height / imageSize.height) + + // Resize using UIGraphicsImageRenderer to control the final dimensions + let renderer = UIGraphicsImageRenderer(size: CGSize(width: imageSize.width * scaleFactor, height: imageSize.height * scaleFactor)) + let resizedImage = renderer.image { context in + context.cgContext.translateBy(x: 0, y: imageSize.height * scaleFactor) + context.cgContext.scaleBy(x: 1, y: -1) + context.cgContext.draw(cgImage, in: CGRect(origin: .zero, size: CGSize(width: imageSize.width * scaleFactor, height: imageSize.height * scaleFactor))) + } + + images.append(resizedImage) + } else { + // No resizing needed, just append the original image + let originalImage = UIImage(cgImage: cgImage) + images.append(originalImage) + } + } + } + + let duration = Double(count) * 0.1 // Adjust frame duration if needed + return UIImage.animatedImage(with: images, duration: duration) + } } diff --git a/Tests/QuickBloxUIKitTests/Repository/Connection/ConnectionRepositoryTests.swift b/Tests/QuickBloxUIKitTests/Repository/Connection/ConnectionRepositoryTests.swift index acfadf8..c0a7032 100644 --- a/Tests/QuickBloxUIKitTests/Repository/Connection/ConnectionRepositoryTests.swift +++ b/Tests/QuickBloxUIKitTests/Repository/Connection/ConnectionRepositoryTests.swift @@ -29,6 +29,43 @@ import Combine } extension ConnectionRepositoryTests { + func testConnectionState() async throws { + let subject = PassthroughSubject() + let results: [String: Result<[Any], Error>] = + [ + MockMethod.checkConnection: .success([ AcyncMockVoid { + subject.send(.disconnected()) + }]), + MockMethod.connect: .success([ AcyncMockVoid { + subject.send(.connecting()) + subject.send(.connected) + }]), + MockMethod.disconnect: .success([ AcyncMockVoid { + subject.send(.disconnected()) + }]) + ] + let reposytory = repository(mock: results, + connection: subject.eraseToAnyPublisher()) + + let expectation = expectation(description: "unauthorized") + expectation.expectedFulfillmentCount = 4 + reposytory.statePublisher.sink { state in + switch state { + case .unauthorized: XCTFail("unauthorized") + case .authorized: XCTFail("authorized") + case .disconnected: expectation.fulfill() + case .connecting: expectation.fulfill() + case .connected: expectation.fulfill() + } + }.store(in: &cancellables) + + try await reposytory.checkConnection() + try await reposytory.connect() + try await reposytory.disconnect() + + await fulfillment(of: [expectation], timeout: 0.3) + } + func testConnection() async throws { let results: [String: Result<[Any], Error>] = [MockMethod.connect: .failure(RemoteDataSourceException.unauthorised())] diff --git a/Tests/QuickBloxUIKitTests/Repository/File/FilesRepositoryTests.swift b/Tests/QuickBloxUIKitTests/Repository/File/FilesRepositoryTests.swift index 43a7e80..6b14c4d 100644 --- a/Tests/QuickBloxUIKitTests/Repository/File/FilesRepositoryTests.swift +++ b/Tests/QuickBloxUIKitTests/Repository/File/FilesRepositoryTests.swift @@ -111,6 +111,20 @@ extension FilesRepositoryTests { ) } + func testGetFromRemote() async throws { + let remoteFileDTO = RemoteFileDTO(id: Test.id, + ext: .png, + data: imageData, + public: false) + + remoteDataSourceMock.results[RemoteMethod.getFile] = + .success([AcyncMockReturn { [remoteFileDTO] }]) + + let result = try await repository.get(fileFromRemote: Test.id) + + XCTAssertEqual(result.data, imageData) + } + func testGetFromRemoteNotFound() async throws { remoteDataSourceMock.results[RemoteMethod.getFile] = .success([AcyncMockError { diff --git a/Tests/QuickBloxUIKitTests/Repository/RemoteDataSourceMock.swift b/Tests/QuickBloxUIKitTests/Repository/RemoteDataSourceMock.swift index c05f535..e365805 100644 --- a/Tests/QuickBloxUIKitTests/Repository/RemoteDataSourceMock.swift +++ b/Tests/QuickBloxUIKitTests/Repository/RemoteDataSourceMock.swift @@ -46,7 +46,6 @@ class RemoteDataSourceMock: Mock { } extension RemoteDataSourceMock: RemoteDataSourceProtocol { - var eventPublisher: AnyPublisher { return event } @@ -112,6 +111,10 @@ extension RemoteDataSourceMock: RemoteDataSourceProtocol { _ = try mock().get() } + func send(forwardMessageToRemote dto: RemoteMessageDTO) async throws { + _ = try mock().get() + } + func update(message dto: RemoteMessageDTO) async throws -> RemoteMessageDTO { return try mock().getFirst() } @@ -147,4 +150,12 @@ extension RemoteDataSourceMock: RemoteDataSourceProtocol { func delete(file dto: RemoteFileDTO) async throws { try await mock().callAcyncVoid() } + + func answerAssist(message dto: RemoteAnswerAssistMessageDTO) async throws -> String { + return try mock().getFirst() + } + + func translate(message dto: RemoteTranslateMessageDTO) async throws -> String { + return try mock().getFirst() + } } diff --git a/Tests/QuickBloxUIKitTests/Source/Flile/LocalFileDataSourceTests.swift b/Tests/QuickBloxUIKitTests/Source/Flile/LocalFileDataSourceTests.swift index 22fb269..928a708 100644 --- a/Tests/QuickBloxUIKitTests/Source/Flile/LocalFileDataSourceTests.swift +++ b/Tests/QuickBloxUIKitTests/Source/Flile/LocalFileDataSourceTests.swift @@ -63,6 +63,15 @@ final class LocalFileDataSourceTests: XCTestCase { ) } + func testCreateLocalFileAlreadyExist() async throws { + _ = try await source.createFile(LocalFileDTO.default) + + await XCTAssertThrowsException( + _ = try await source.createFile(LocalFileDTO.default), + equelTo: DataSourceException.alreadyExist() + ) + } + func testGetLocalFileNotFound() async throws { await XCTAssertThrowsException( try await source.getFile(LocalFileDTO.searching), diff --git a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Dialog.swift b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Dialog.swift index 61dcc67..f3fdcf1 100644 --- a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Dialog.swift +++ b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Dialog.swift @@ -11,6 +11,26 @@ import XCTest @testable import QuickBloxDomain @testable import QuickBloxData +//MARK: Save Dialog +extension LocalDataSourceTest { + func testSaveDialogAlreadyExist() async throws { + await storage.dialogsPublisher + .dropFirst() + .sink { dialogs in + let dialog = dialogs.first(where: { $0.id == Test.stringId }) + XCTAssertEqual(dialog, LocalDialogDTO.default) + } + .store(in: &cancellables) + + _ = try await storage.save(dialog: LocalDialogDTO.default) + + await XCTAssertThrowsException( + try await storage.save(dialog: LocalDialogDTO.default), + equelTo: DataSourceException.alreadyExist() + ) + } +} + //MARK: Get Dialog extension LocalDataSourceTest { func testGetDialogNotFound() async throws { diff --git a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Message.swift b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Message.swift index 8ada567..a0ef12f 100644 --- a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Message.swift +++ b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+Message.swift @@ -11,8 +11,46 @@ import XCTest @testable import QuickBloxDomain @testable import QuickBloxData +//MARK: Save Message +extension LocalDataSourceTest { + func testSaveMessageAlreadyExist() async throws { + let saved = try await createAndSaveMessage() + await XCTAssertThrowsException( + try await storage.save(message: saved), + equelTo: DataSourceException.alreadyExist() + ) + } +} + +//MARK: Delete Message +extension LocalDataSourceTest { + func testDeletedMessageNotFound() async throws { + await XCTAssertThrowsException( + try await storage.delete(message: LocalMessageDTO.default), + equelTo: DataSourceException.notFound() + ) + } + + func testDeleteMessage() async throws { + let saved = try await createAndSaveMessage() + + try await storage.delete(message: saved) + + await XCTAssertThrowsException( + try await storage.update(message: LocalMessageDTO.default), + equelTo: DataSourceException.notFound() + ) + } +} + //MARK: Update Message extension LocalDataSourceTest { + func testUpdatedMessageNotFound() async throws { + await XCTAssertThrowsException( + try await storage.update(message: LocalMessageDTO.default), + equelTo: DataSourceException.notFound() + ) + } func testUpdateMessage() async throws { _ = try await createAndSaveMessage() diff --git a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+User.swift b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+User.swift new file mode 100644 index 0000000..635c485 --- /dev/null +++ b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest+User.swift @@ -0,0 +1,24 @@ +// +// LocalDataSourceTest+User.swift +// QuickBloxUIKitTests +// +// Created by Injoit on 22.01.2023. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import XCTest +@testable import QuickBloxData +@testable import QuickBloxDomain + + + +//MARK: Save User +extension LocalDataSourceTest { + func testSaveUserAlreadyExist() async throws { + let saved = try await createAndSaveUser() + await XCTAssertThrowsException( + try await storage.save(user: saved), + equelTo: DataSourceException.alreadyExist() + ) + } +} diff --git a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest.swift b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest.swift index 9b0223e..34517c9 100644 --- a/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest.swift +++ b/Tests/QuickBloxUIKitTests/Source/Local/LocalDataSourceTest.swift @@ -212,6 +212,11 @@ extension LocalDataSourceTest { equelTo: DataSourceException.notFound() ) + await XCTAssertThrowsException( + try await storage.update(message: message), + equelTo: DataSourceException.notFound() + ) + await XCTAssertThrowsException( try await storage.get(user: user), equelTo: DataSourceException.notFound()) diff --git a/Tests/QuickBloxUIKitTests/UseCase/AI/AITests.swift b/Tests/QuickBloxUIKitTests/UseCase/AI/AITests.swift new file mode 100644 index 0000000..e18c98b --- /dev/null +++ b/Tests/QuickBloxUIKitTests/UseCase/AI/AITests.swift @@ -0,0 +1,89 @@ +// +// AITests.swift +// QuickBloxUIKit +// +// Created by Injoit on 16.05.2024. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import XCTest + +@testable import QuickBloxDomain +@testable import QuickBloxData + +final class AITests: XCTestCase { + + var aiRepoMock: AIRepositoryMock! + + override func setUp() async throws { + try await super.setUp() + + aiRepoMock = AIRepositoryMock() + } + + override func tearDown() async throws { + try await super.tearDown() + + aiRepoMock = nil + } + + typealias AIMethod = AIRepositoryMock.MockMethod + + func testAnswerAssist() async throws { + + let answer = "Test Answer from Remote" + + aiRepoMock.results[AIMethod.answerAssistFromRemote] = + .success([AcyncMockReturn { answer }]) + + let dialogId = "a1s2d2f2g2hg2h" + + var userMessage = Message(id: "1c2c345616846", dialogId: dialogId, type: MessageType.chat) + userMessage.isOwnedByCurrentUser = false + userMessage.text = "Who are you?" + + var ownedMessage = Message(id: "1c2c3", dialogId: dialogId, type: MessageType.chat) + ownedMessage.isOwnedByCurrentUser = true + ownedMessage.text = "history 1" + + var message1 = Message(id: "1c2c3sdd", dialogId: dialogId, type: MessageType.chat) + message1.isOwnedByCurrentUser = false + message1.text = "history 11" + + var message2 = Message(id: "1c3sdd", dialogId: dialogId, type: MessageType.chat) + message2.isOwnedByCurrentUser = false + message2.text = "history 113" + + let history = [ownedMessage, message1, message2] + + let useCase = AnswerAssist(message: userMessage, + history: history, + smartChatAssistantId: "1s2d3d4f5f6f7f8f9f", + repo: aiRepoMock) + + let answerFromRemote = try await useCase.execute() + + print("answerFromRemote = \(answerFromRemote)") + + XCTAssertEqual(answer, answerFromRemote) + } + + func testTranslate() async throws { + + let translate = "Test Translate from Remote" + + aiRepoMock.results[AIMethod.translateFromRemote] = + .success([AcyncMockReturn { translate }]) + + let useCase = Translate(translate, + smartChatAssistantId: "1s2d3d4f5f6f7f8f9f", + languageCode: "uk", + repo: aiRepoMock) + + let translateFromRemote = try await useCase.execute() + + print("translateFromRemote = \(translateFromRemote)") + + XCTAssertEqual(translate, translateFromRemote) + } +} diff --git a/Tests/QuickBloxUIKitTests/UseCase/Dialog/SyncDialogTests.swift b/Tests/QuickBloxUIKitTests/UseCase/Dialog/SyncDialogTests.swift new file mode 100644 index 0000000..06fa712 --- /dev/null +++ b/Tests/QuickBloxUIKitTests/UseCase/Dialog/SyncDialogTests.swift @@ -0,0 +1,84 @@ +// +// SyncDialogTests.swift +// QuickBloxUIKit +// +// Created by Injoit on 24.04.2023. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import XCTest +import Combine +@testable import QuickBloxDomain +@testable import QuickBloxData + +final class SyncDialogTests: XCTestCase { + + var dialogsRepoMock: DialogsRepositoryMock! + var usersRepoMock: UsersRepositoryMock! + var messagesRepoMock: MessagesRepositoryMock! + + + private var cancellables: Set! + + override func setUpWithError() throws { + try super.setUpWithError() + cancellables = Set() + dialogsRepoMock = DialogsRepositoryMock() + usersRepoMock = UsersRepositoryMock() + } + + override func tearDownWithError() throws { + cancellables = nil + dialogsRepoMock = nil + usersRepoMock = nil + + try super.tearDownWithError() + } + + typealias DialogsMethod = DialogsRepositoryMock.MockMethod + typealias UserssMethod = UsersRepositoryMock.MockMethod + + func testExecute() async throws { + let dialog = Dialog(id: "testId", + type: .private, + participantsIds: ["1", "2"]) + + let users = [ + User(id: "1", name: "Bob"), + User(id: "2", name: "Alice") + ] + + dialogsRepoMock.results[DialogsMethod.getFromRemote] = + .success([AcyncMockReturn { dialog }]) + + usersRepoMock.results[UserssMethod.getUsersFromRemote] = + .success([ AcyncMockReturn { users } ]) + + dialogsRepoMock.results[DialogsMethod.saveDialogToLocal] = + .success([AcyncMockVoid { }]) + + + let useCase = SyncDialog(dialogId: dialog.id, + dialogsRepo: dialogsRepoMock, + usersRepo: usersRepoMock, + messageRepo: MessagesRepositoryMock()) + + let syncDialogExp = expectation(description: "sync dialog") + useCase.execute().sink(receiveValue: { result in + XCTAssertEqual(result.id, dialog.id) + syncDialogExp.fulfill() + }).store(in: &cancellables) + + let dialogs = [ + dialog, + Dialog(id: "otherId", type: .public) + ] + let localSub = CurrentValueSubject<[Dialog], Never>(dialogs) + dialogsRepoMock.localPublisher = localSub.eraseToAnyPublisher() + + await fulfillment(of: [syncDialogExp], timeout: 10.0) + + XCTAssertTrue(usersRepoMock.mockMetods.isEmpty) + XCTAssertTrue(dialogsRepoMock.mockMetods.isEmpty) + } +} diff --git a/Tests/QuickBloxUIKitTests/UseCase/Moc/AIRepositoryMock.swift b/Tests/QuickBloxUIKitTests/UseCase/Moc/AIRepositoryMock.swift new file mode 100644 index 0000000..bf7d704 --- /dev/null +++ b/Tests/QuickBloxUIKitTests/UseCase/Moc/AIRepositoryMock.swift @@ -0,0 +1,28 @@ +// +// AIRepositoryMock.swift +// QuickBloxUIKit +// +// Created by Injoit on 20.03.2023. +// Copyright © 2023 QuickBlox. All rights reserved. +// + +import QuickBloxDomain +import QuickBloxData + +class AIRepositoryMock: Mock { } + +extension AIRepositoryMock: AIRepositoryProtocol { + + struct MockMethod { + static let answerAssistFromRemote = "answerAssist(message:)" + static let translateFromRemote = "translate(message:)" + } + + func answerAssist(message entity: AnswerAssistMessage) async throws -> String { + try await mock().callAcyncReturn() + } + + func translate(message entity: TranslateMessage) async throws -> String { + try await mock().callAcyncReturn() + } +} diff --git a/Tests/QuickBloxUIKitTests/UseCase/Moc/MessagesRepositoryMock.swift b/Tests/QuickBloxUIKitTests/UseCase/Moc/MessagesRepositoryMock.swift index 7bd7847..9079011 100644 --- a/Tests/QuickBloxUIKitTests/UseCase/Moc/MessagesRepositoryMock.swift +++ b/Tests/QuickBloxUIKitTests/UseCase/Moc/MessagesRepositoryMock.swift @@ -28,6 +28,10 @@ extension MessagesRepositoryMock: MessagesRepositoryProtocol { try await mock().callAcyncVoid() } + func send(forwardMessageToRemote entity: Message) async throws { + try await mock().callAcyncVoid() + } + func save(messageToLocal entity: Message) async throws { try await mock().callAcyncVoid() }