diff --git a/.swiftlint.yml b/.swiftlint.yml index 5eafdf8..b52a95c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -54,8 +54,12 @@ trailing_semicolon: severity: error type_name: - min_length: 3 - severity: warning + min_length: + warninig: 3 + error: 0 + max_length: + warninig: 40 + error: 80 identifier_name: min_length: 3 @@ -73,6 +77,7 @@ identifier_name: - tz - to - db + - _db # Disable rules from the default enabled set. disabled_rules: @@ -112,4 +117,5 @@ unused_declaration: # paths to ignore during linting. Takes precedence over `included`. excluded: - - SomePathHere + - .swiftgen + - "**/Generated" diff --git a/ConversationsClassic/AppCore/Actions/AccountsActions.swift b/ConversationsClassic/AppCore/Actions/AccountsActions.swift deleted file mode 100644 index b2b6890..0000000 --- a/ConversationsClassic/AppCore/Actions/AccountsActions.swift +++ /dev/null @@ -1,12 +0,0 @@ -enum AccountsAction: Codable { - case accountsListUpdated(accounts: [Account]) - - case goTo(AccountNavigationState) - - case tryAddAccountWithCredentials(login: String, password: String) - case addAccountError(jid: String, reason: String?) - - case makeAccountPermanent(account: Account) - - case clientServerFeaturesUpdated(jid: String, features: [ServerFeature]) -} diff --git a/ConversationsClassic/AppCore/Actions/AppActions.swift b/ConversationsClassic/AppCore/Actions/AppActions.swift deleted file mode 100644 index 2cb786d..0000000 --- a/ConversationsClassic/AppCore/Actions/AppActions.swift +++ /dev/null @@ -1,15 +0,0 @@ -enum AppAction: Codable { - case info(String) - case flushState - case changeFlow(_ flow: AppFlow) - - case startAction(_ action: StartAction) - case databaseAction(_ action: DatabaseAction) - case accountsAction(_ action: AccountsAction) - case xmppAction(_ action: XMPPAction) - case rostersAction(_ action: RostersAction) - case chatsAction(_ action: ChatsAction) - case conversationAction(_ action: ConversationAction) - case sharingAction(_ action: SharingAction) - case fileAction(_ action: FileAction) -} diff --git a/ConversationsClassic/AppCore/Actions/ChatsActions.swift b/ConversationsClassic/AppCore/Actions/ChatsActions.swift deleted file mode 100644 index b247cd2..0000000 --- a/ConversationsClassic/AppCore/Actions/ChatsActions.swift +++ /dev/null @@ -1,10 +0,0 @@ -enum ChatsAction: Codable { - case chatsListUpdated(chats: [Chat]) - - case startChat(accountJid: String, participantJid: String) - case chatStarted(chat: Chat) - - case createNewChat(accountJid: String, participantJid: String) - case chatCreated(chat: Chat) - case chatCreationFailed(reason: String) -} diff --git a/ConversationsClassic/AppCore/Actions/ConversationActions.swift b/ConversationsClassic/AppCore/Actions/ConversationActions.swift deleted file mode 100644 index 6bb8b32..0000000 --- a/ConversationsClassic/AppCore/Actions/ConversationActions.swift +++ /dev/null @@ -1,10 +0,0 @@ -enum ConversationAction: Codable { - case makeConversationActive(chat: Chat, roster: Roster?) - - case messagesUpdated(messages: [Message]) - - case sendMessage(from: String, to: String, body: String) - case setReplyText(String) - - case sendMediaMessages(from: String, to: String, messagesIds: [String], localFilesNames: [String]) -} diff --git a/ConversationsClassic/AppCore/Actions/DatabaseActions.swift b/ConversationsClassic/AppCore/Actions/DatabaseActions.swift deleted file mode 100644 index b474cae..0000000 --- a/ConversationsClassic/AppCore/Actions/DatabaseActions.swift +++ /dev/null @@ -1,12 +0,0 @@ -enum DatabaseAction: Codable { - case storedAccountsLoaded(accounts: [Account]) - case loadingStoredAccountsFailed - case updateAccountFailed - - case storedRostersLoaded(rosters: [Roster]) - case storedChatsLoaded(chats: [Chat]) - - case storeMessageFailed(reason: String) - - case updateAttachmentFailed(id: String, reason: String) -} diff --git a/ConversationsClassic/AppCore/Actions/FileActions.swift b/ConversationsClassic/AppCore/Actions/FileActions.swift deleted file mode 100644 index 46187c6..0000000 --- a/ConversationsClassic/AppCore/Actions/FileActions.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -enum FileAction: Stateable { - case downloadAttachmentFile(messageId: String, attachmentRemotePath: URL) - case attachmentFileDownloaded(messageId: String, localName: String) - case downloadingAttachmentFileFailed(messageId: String, reason: String) - - case createAttachmentThumbnail(messageId: String, localName: String) - case attachmentThumbnailCreated(messageId: String, thumbnailName: String) - - case fetchItemsFromGallery - case itemsFromGalleryFetched(items: [SharingGalleryItem]) - - case copyGalleryItemsForUploading(items: [SharingGalleryItem]) - case copyCameraCapturedForUploading(media: Data, type: SharingCameraMediaType) - case itemsCopiedForUploading(newMessageIds: [String], localNames: [String]) -} diff --git a/ConversationsClassic/AppCore/Actions/RostersActions.swift b/ConversationsClassic/AppCore/Actions/RostersActions.swift deleted file mode 100644 index b4ba796..0000000 --- a/ConversationsClassic/AppCore/Actions/RostersActions.swift +++ /dev/null @@ -1,12 +0,0 @@ -enum RostersAction: Codable { - case addRoster(ownerJID: String, contactJID: String, name: String?, groups: [String]) - case addRosterDone(jid: String) - case addRosterError(reason: String) - - case rostersListUpdated([Roster]) - - case markRosterAsLocallyDeleted(ownerJID: String, contactJID: String) - case unmarkRosterAsLocallyDeleted(ownerJID: String, contactJID: String) - case deleteRoster(ownerJID: String, contactJID: String) - case rosterDeletingFailed(reason: String) -} diff --git a/ConversationsClassic/AppCore/Actions/SharingActions.swift b/ConversationsClassic/AppCore/Actions/SharingActions.swift deleted file mode 100644 index 6d8d0ee..0000000 --- a/ConversationsClassic/AppCore/Actions/SharingActions.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -enum SharingAction: Stateable { - case showSharing(Bool) - - case shareLocation(lat: Double, lon: Double) - case shareContact(jid: String) - case shareDocuments([Data], [String]) - case shareMedia(ids: [String]) - - case checkCameraAccess - case setCameraAccess(Bool) - - case checkGalleryAccess - case setGalleryAccess(Bool) - case galleryItemsUpdated(items: [SharingGalleryItem]) - - case cameraCaptured(media: Data, type: SharingCameraMediaType) - - case retrySharing(messageId: String) -} diff --git a/ConversationsClassic/AppCore/Actions/StartActions.swift b/ConversationsClassic/AppCore/Actions/StartActions.swift deleted file mode 100644 index 7d41c7c..0000000 --- a/ConversationsClassic/AppCore/Actions/StartActions.swift +++ /dev/null @@ -1,5 +0,0 @@ -enum StartAction: Codable { - case loadStoredAccounts - - case goTo(StartNavigationState) -} diff --git a/ConversationsClassic/AppCore/Actions/XMPPActions.swift b/ConversationsClassic/AppCore/Actions/XMPPActions.swift deleted file mode 100644 index 8876ad3..0000000 --- a/ConversationsClassic/AppCore/Actions/XMPPActions.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -enum XMPPAction: Codable { - case clientConnectionChanged(jid: String, state: ConnectionStatus) - case xmppMessageReceived(Message) - - case xmppMessageSent(Message) - case xmppMessageSendFailed(msgId: String) - case xmppMessageSendSuccess(msgId: String) - - case xmppSharingTryUpload(Message) - case xmppSharingUploadFailed(msgId: String, reason: String) - case xmppSharingUploadSuccess(msgId: String, attachmentRemotePath: String) - case serverFeaturesLoaded(jid: String, features: [String]) - - case xmppLoadArchivedMessages(jid: String, to: String?, fromDate: Date) -} diff --git a/ConversationsClassic/AppCore/AppStore.swift b/ConversationsClassic/AppCore/AppStore.swift deleted file mode 100644 index 11ad860..0000000 --- a/ConversationsClassic/AppCore/AppStore.swift +++ /dev/null @@ -1,102 +0,0 @@ -// This file declare global state object for whole app -// and reducers/actions/middleware types. Core of app. -import Combine -import Foundation - -typealias Stateable = Codable & Equatable -typealias AppStore = Store -typealias Reducer = (inout State, Action) -> Void -typealias Middleware = (State, Action) -> AnyPublisher? - -final class Store: ObservableObject { - @Published private(set) var state: State - - // Serial queue for performing any actions sequentially - private let serialQueue = DispatchQueue(label: "im.narayana.conversations.classic.serial.queue", qos: .userInteractive) - private let middlewareQueue = DispatchQueue(label: "im.narayana.conversations.classic.middleware.queue", qos: .default, attributes: .concurrent) - - private let reducer: Reducer - private let middlewares: [Middleware] - private var middlewareCancellables: Set = [] - - // Init - init( - initialState: State, - reducer: @escaping Reducer, - middlewares: [Middleware] = [] - ) { - state = initialState - self.reducer = reducer - self.middlewares = middlewares - } - - // Run reducers/middlewares - func dispatch(_ action: Action) { - if !Thread.isMainThread { - print("❌WARNING!: AppStore.dispatch should be called from the main thread") - } - serialQueue.sync { [weak self] in - guard let wSelf = self else { return } - let newState = wSelf.dispatch(wSelf.state, action) - wSelf.state = newState - } - } - - private func dispatch(_ currentState: State, _ action: Action) -> State { - // Do reducing - var startTime = CFAbsoluteTimeGetCurrent() - var newState = currentState - reducer(&newState, action) - var timeElapsed = CFAbsoluteTimeGetCurrent() - startTime - if timeElapsed > 0.05 { - #if DEBUG - print( - """ - -- - (Ignore this warning ONLY in case, when execution is paused by your breakpoint) - 🕐Execution time: \(timeElapsed) - ❌WARNING! Some reducers work too long! It will lead to issues in production build! - Because of execution each action is synchronous the any stuck will reduce performance dramatically. - Probably you need check which part of reducer/middleware should be async (wrapped with Futures, as example) - -- - """ - ) - #else - #endif - } - - // Dispatch all middleware functions - for middleware in middlewares { - guard let middleware = middleware(newState, action) else { - break - } - startTime = CFAbsoluteTimeGetCurrent() - middleware - .subscribe(on: middlewareQueue) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] action in - self?.dispatch(action) - }) - .store(in: &middlewareCancellables) - timeElapsed = CFAbsoluteTimeGetCurrent() - startTime - if timeElapsed > 0.05 { - #if DEBUG - print( - """ - -- - (Ignore this warning ONLY in case, when execution is paused by your breakpoint) - 🕐Execution time: \(timeElapsed) - ❌WARNING! Middleware work too long! It will lead to issues in production build! - Because of execution each action is synchronous the any stuck will reduce performance dramatically. - Probably you need check which part of reducer/middleware should be async (wrapped with Futures, as example) - -- - """ - ) - #else - #endif - } - } - - return newState - } -} diff --git a/ConversationsClassic/AppCore/Database/Database+Martin/Database+Martin.swift b/ConversationsClassic/AppCore/Database/Database+Martin/Database+Martin.swift deleted file mode 100644 index aec3ef4..0000000 --- a/ConversationsClassic/AppCore/Database/Database+Martin/Database+Martin.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation -import GRDB -import Martin - -extension Database: MartinsManager {} - -// Check specific implementation in Database+Martin* files diff --git a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinChannelManager.swift b/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinChannelManager.swift deleted file mode 100644 index bac242c..0000000 --- a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinChannelManager.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import GRDB -import Martin - -extension Database: Martin.ChannelManager { - func channels(for _: Martin.Context) -> [any Martin.ChannelProtocol] { - [] - } - - func createChannel(for _: Martin.Context, with _: Martin.BareJID, participantId _: String, nick _: String?, state _: Martin.ChannelState) -> Martin.ConversationCreateResult { - .none - } - - func channel(for _: Martin.Context, with _: Martin.BareJID) -> (any Martin.ChannelProtocol)? { - nil - } - - func close(channel _: any Martin.ChannelProtocol) -> Bool { - false - } -} diff --git a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinRoomManager.swift b/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinRoomManager.swift deleted file mode 100644 index 6a399c4..0000000 --- a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinRoomManager.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -import GRDB -import Martin - -extension Database: Martin.RoomManager { - func rooms(for context: Martin.Context) -> [any Martin.RoomProtocol] { - do { - let rooms: [Room] = try _db.read { db in - try Room.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db) - } - return rooms.map { room in - Martin.RoomBase( - context: context, - jid: context.userBareJid, - nickname: room.nickname, - password: room.password, - dispatcher: QueueDispatcher(label: "room-\(room.id)") - ) - } - } catch { - logIt(.error, "Error fetching channels: \(error.localizedDescription)") - return [] - } - } - - func room(for context: Martin.Context, with roomJid: Martin.BareJID) -> (any Martin.RoomProtocol)? { - do { - let room: Room? = try _db.read { db in - try Room - .filter(Column("account") == context.userBareJid.stringValue) - .filter(Column("id") == roomJid.stringValue) - .fetchOne(db) - } - if let room { - return Martin.RoomBase( - context: context, - jid: context.userBareJid, - nickname: room.nickname, - password: room.password, - dispatcher: QueueDispatcher(label: "room-\(room.id)") - ) - } else { - return nil - } - } catch { - logIt(.error, "Error fetching room: \(error.localizedDescription)") - return nil - } - } - - func createRoom(for context: Martin.Context, with roomJid: Martin.BareJID, nickname: String, password: String?) -> (any Martin.RoomProtocol)? { - do { - let room: Room? = try _db.read { db in - try Room - .filter(Column("account") == context.userBareJid.stringValue) - .filter(Column("id") == roomJid.stringValue) - .fetchOne(db) - } - if let room { - return Martin.RoomBase( - context: context, - jid: context.userBareJid, - nickname: room.nickname, - password: room.password, - dispatcher: QueueDispatcher(label: "room-\(room.id)") - ) - } else { - let room = Room( - id: roomJid.stringValue, - account: context.userBareJid.stringValue, - nickname: nickname, - password: password - ) - try _db.write { db in - try room.save(db) - } - return Martin.RoomBase( - context: context, - jid: context.userBareJid, - nickname: nickname, - password: password, - dispatcher: QueueDispatcher(label: "room-\(room.id)") - ) - } - } catch { - logIt(.error, "Error fetching room: \(error.localizedDescription)") - return nil - } - } - - func close(room: any Martin.RoomProtocol) -> Bool { - do { - try _db.write { db in - try Room - .filter(Column("account") == room.context?.userBareJid.stringValue ?? "") - .filter(Column("id") == room.jid.stringValue) - .deleteAll(db) - } - return true - } catch { - logIt(.error, "Error closing room: \(error.localizedDescription)") - return false - } - } -} diff --git a/ConversationsClassic/AppCore/Files/DownloadManager.swift b/ConversationsClassic/AppCore/Files/DownloadManager.swift deleted file mode 100644 index 67bf06e..0000000 --- a/ConversationsClassic/AppCore/Files/DownloadManager.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -final class DownloadManager { - static let shared = DownloadManager() - - private let urlSession: URLSession - private let downloadQueue = DispatchQueue(label: "com.example.downloadQueue") - private var activeDownloads = Set() - - init() { - let configuration = URLSessionConfiguration.default - urlSession = URLSession(configuration: configuration) - } - - func enqueueDownload(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) { - downloadQueue.async { - if self.activeDownloads.contains(url) { - print("Download for this file is already in queue.") - return - } - - self.activeDownloads.insert(url) - - let task = self.urlSession.downloadTask(with: url) { tempLocalUrl, _, error in - self.downloadQueue.async { - self.activeDownloads.remove(url) - - guard let tempLocalUrl = tempLocalUrl, error == nil else { - completion(error) - return - } - - do { - if FileManager.default.fileExists(atPath: localUrl.path) { - try FileManager.default.removeItem(at: localUrl) - } - let data = try Data(contentsOf: tempLocalUrl) - try data.write(to: localUrl) - completion(nil) - } catch let writeError { - completion(writeError) - } - } - } - task.resume() - } - } -} diff --git a/ConversationsClassic/AppCore/Files/FileProcessing.swift b/ConversationsClassic/AppCore/Files/FileProcessing.swift deleted file mode 100644 index 9845fac..0000000 --- a/ConversationsClassic/AppCore/Files/FileProcessing.swift +++ /dev/null @@ -1,301 +0,0 @@ -import Foundation -import Photos -import UIKit - -final class FileProcessing { - static let shared = FileProcessing() - - static var fileFolder: URL { - // swiftlint:disable:next force_unwrapping - let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let subdirectoryURL = documentsURL.appendingPathComponent(Const.fileFolder) - if !FileManager.default.fileExists(atPath: subdirectoryURL.path) { - try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) - } - return subdirectoryURL - } - - func createThumbnail(localName: String) -> String? { - let thumbnailFileName = "thumb_\(localName)" - let thumbnailUrl = FileProcessing.fileFolder.appendingPathComponent(thumbnailFileName) - let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) - - // check if thumbnail already exists - if FileManager.default.fileExists(atPath: thumbnailUrl.path) { - return thumbnailFileName - } - - // create thumbnail if not exists - switch localName.attachmentType { - case .image: - guard let image = UIImage(contentsOfFile: localUrl.path) else { - print("FileProcessing: Error loading image: \(localUrl)") - return nil - } - let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) - guard let thumbnail = scaleAndCropImage(image, targetSize) else { - print("FileProcessing: Error scaling image: \(localUrl)") - return nil - } - guard let data = thumbnail.pngData() else { - print("FileProcessing: Error converting thumbnail of \(localUrl) to data") - return nil - } - do { - try data.write(to: thumbnailUrl) - return thumbnailFileName - } catch { - print("FileProcessing: Error writing thumbnail: \(error)") - return nil - } - - default: - return nil - } - } - - func fetchGallery() -> [SharingGalleryItem] { - let items = syncGalleryEnumerate() - .map { - SharingGalleryItem( - id: $0.localIdentifier, - type: $0.mediaType == .image ? .photo : .video, - duration: $0.mediaType == .video ? $0.duration.minAndSec : nil - ) - } - return items - } - - func fillGalleryItemsThumbnails(items: [SharingGalleryItem]) -> [SharingGalleryItem] { - let ids = items - .filter { $0.thumbnail == nil } - .map { $0.id } - - let assets = syncGalleryEnumerate(ids) - return assets.compactMap { asset in - if asset.mediaType == .image { - return syncGalleryProcessImage(asset) { [weak self] image in - if let thumbnail = self?.scaleAndCropImage(image, CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)) { - let data = thumbnail.jpegData(compressionQuality: 1.0) ?? Data() - return SharingGalleryItem(id: asset.localIdentifier, type: .photo, thumbnail: data) - } else { - return nil - } - } - } else if asset.mediaType == .video { - return syncGalleryProcessVideo(asset) { [weak self] avAsset in - // swiftlint:disable:next force_cast - let assetURL = avAsset as! AVURLAsset - let url = assetURL.url - if let thumbnail = self?.generateVideoThumbnail(url, CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)) { - let data = thumbnail.jpegData(compressionQuality: 1.0) ?? Data() - return SharingGalleryItem( - id: asset.localIdentifier, - type: .video, - thumbnail: data, - duration: asset.duration.minAndSec - ) - } else { - return nil - } - } - } else { - return nil - } - } - } - - // This function also creates new ids for messages for each new attachment - func copyGalleryItemsForUploading(items: [SharingGalleryItem]) -> [(String, String)] { - let assets = syncGalleryEnumerate(items.map { $0.id }) - return assets - .compactMap { asset in - let newMessageId = UUID().uuidString - let fileId = UUID().uuidString - if asset.mediaType == .image { - return syncGalleryProcessImage(asset) { image in - let localName = "\(newMessageId)_\(fileId).jpg" - let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) - if let data = image.jpegData(compressionQuality: 1.0) { - do { - try data.write(to: localUrl) - return (newMessageId, localName) - } catch { - return nil - } - } else { - return nil - } - } - } else if asset.mediaType == .video { - return syncGalleryProcessVideo(asset) { avAsset in - // swiftlint:disable:next force_cast - let assetURL = avAsset as! AVURLAsset - let url = assetURL.url - let localName = "\(newMessageId)_\(fileId).mov" - let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) - do { - try FileManager.default.copyItem(at: url, to: localUrl) - return (newMessageId, localName) - } catch { - return nil - } - } - } else { - return nil - } - } - } - - // This function also creates new id for file from camera capturing - func copyCameraCapturedForUploading(media: Data, type: SharingCameraMediaType) -> (String, String)? { - let newMessageId = UUID().uuidString - let fileId = UUID().uuidString - let localName: String - switch type { - case .photo: - localName = "\(newMessageId)_\(fileId).jpg" - case .video: - localName = "\(newMessageId)_\(fileId).mov" - } - let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) - do { - try media.write(to: localUrl) - return (newMessageId, localName) - } catch { - return nil - } - } - - // This function also creates new id for file from document sharing - func copyDocumentsForUploading(data: [Data], extensions: [String]) -> [(String, String)] { - data.enumerated().compactMap { index, data in - let newMessageId = UUID().uuidString - let fileId = UUID().uuidString - let localName = "\(newMessageId)_\(fileId).\(extensions[index])" - let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) - do { - try data.write(to: localUrl) - return (newMessageId, localName) - } catch { - print("FileProcessing: Error writing document: \(error)") - return nil - } - } - } -} - -private extension FileProcessing { - func scaleAndCropImage(_ img: UIImage, _ size: CGSize) -> UIImage? { - let aspect = img.size.width / img.size.height - let targetAspect = size.width / size.height - var newWidth: CGFloat - var newHeight: CGFloat - if aspect < targetAspect { - newWidth = size.width - newHeight = size.width / aspect - } else { - newHeight = size.height - newWidth = size.height * aspect - } - - UIGraphicsBeginImageContextWithOptions(size, false, 0.0) - img.draw(in: CGRect(x: (size.width - newWidth) / 2, y: (size.height - newHeight) / 2, width: newWidth, height: newHeight)) - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return newImage - } - - func syncGalleryEnumerate(_ ids: [String]? = nil) -> [PHAsset] { - var result: [PHAsset] = [] - - let group = DispatchGroup() - DispatchQueue.global(qos: .userInitiated).sync { - let fetchOptions = PHFetchOptions() - fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] - if let ids { - fetchOptions.predicate = NSPredicate(format: "localIdentifier IN %@", ids) - } - let assets = PHAsset.fetchAssets(with: fetchOptions) - assets.enumerateObjects { asset, _, _ in - group.enter() - result.append(asset) - group.leave() - } - } - group.wait() - return result - } - - func syncGalleryProcess(_ assets: [PHAsset], _ block: @escaping (PHAsset) -> T) -> [T] { - var result: [T] = [] - let group = DispatchGroup() - DispatchQueue.global(qos: .userInitiated).sync { - for asset in assets { - group.enter() - let res = block(asset) - result.append(res) - group.leave() - } - } - group.wait() - return result - } - - func syncGalleryProcessImage(_ asset: PHAsset, _ block: @escaping (UIImage) -> T?) -> T? { - var result: T? - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).sync { - let options = PHImageRequestOptions() - options.version = .original - options.isSynchronous = true - PHImageManager.default().requestImage( - for: asset, - targetSize: PHImageManagerMaximumSize, - contentMode: .aspectFill, - options: options - ) { image, _ in - if let image { - result = block(image) - } else { - result = nil - } - semaphore.signal() - } - } - semaphore.wait() - return result - } - - func syncGalleryProcessVideo(_ asset: PHAsset, _ block: @escaping (AVAsset) -> T?) -> T? { - var result: T? - let semaphore = DispatchSemaphore(value: 0) - _ = DispatchQueue.global(qos: .userInitiated).sync { - PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in - if let avAsset { - result = block(avAsset) - } else { - result = nil - } - semaphore.signal() - } - } - semaphore.wait() - return result - } - - func generateVideoThumbnail(_ url: URL, _ size: CGSize) -> UIImage? { - let asset = AVAsset(url: url) - let assetImgGenerate = AVAssetImageGenerator(asset: asset) - assetImgGenerate.appliesPreferredTrackTransform = true - let time = CMTimeMakeWithSeconds(Float64(1), preferredTimescale: 600) - do { - let cgImage = try assetImgGenerate.copyCGImage(at: time, actualTime: nil) - let image = UIImage(cgImage: cgImage) - return scaleAndCropImage(image, size) - } catch { - return nil - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/AccountsMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/AccountsMiddleware.swift deleted file mode 100644 index 04eb2db..0000000 --- a/ConversationsClassic/AppCore/Middlewares/AccountsMiddleware.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Combine -import Foundation - -final class AccountsMiddleware { - static let shared = AccountsMiddleware() - - private lazy var allFeatures: [ServerFeature] = { - guard - let url = Bundle.main.url(forResource: "server_features", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let loaded = try? PropertyListDecoder().decode([ServerFeature].self, from: data) - else { - return [] - } - return loaded - }() - - func middleware(state: AppState, action: AppAction) -> AnyPublisher { - switch action { - case .databaseAction(.storedAccountsLoaded(let accounts)): - return Just(.accountsAction(.accountsListUpdated(accounts: accounts))) - .eraseToAnyPublisher() - - case .xmppAction(.clientConnectionChanged(let jid, let connectionStatus)): - return Deferred { - Future { promise in - guard let account = state.accountsState.accounts.first(where: { $0.bareJid == jid }) else { - promise(.success(.info("AccountsMiddleware: account not found for jid \(jid)"))) - return - } - if account.isTemp { - switch connectionStatus { - case .connected: - promise(.success(.accountsAction(.makeAccountPermanent(account: account)))) - - case .disconnected(let reason): - if reason != "No error!" { - promise(.success(.accountsAction(.addAccountError(jid: jid, reason: reason)))) - } else { - promise(.success(.info("AccountsMiddleware: account \(jid) disconnected with no error"))) - } - - default: - promise(.success(.info("AccountsMiddleware: account \(jid) connection status changed to \(connectionStatus)"))) - } - } else { - promise(.success(.info("AccountsMiddleware: account \(jid) is not temporary, ignoring"))) - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.serverFeaturesLoaded(let jid, let features)): - return Deferred { - Future { [weak self] promise in - let serverFeatures = features - .compactMap { featureId in - self?.allFeatures.first(where: { $0.xmppId == featureId }) - } - promise(.success(.accountsAction(.clientServerFeaturesUpdated(jid: jid, features: serverFeatures)))) - } - } - .eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/ArchivedMessagesMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/ArchivedMessagesMiddleware.swift deleted file mode 100644 index bf0aa08..0000000 --- a/ConversationsClassic/AppCore/Middlewares/ArchivedMessagesMiddleware.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Combine -import Foundation - -final class ArchivedMessagesMiddleware { - static let shared = ArchivedMessagesMiddleware() - - func middleware(state _: AppState, action: AppAction) -> AnyPublisher { - switch action { - // case .conversationAction(.messagesUpdated(let messages)): - // if state.conversationsState.archivedMessagesRequested { - // return Empty().eraseToAnyPublisher() - // } else { - // guard let chat = state.conversationsState.currentChat else { - // return Empty().eraseToAnyPublisher() - // } - // return Deferred { - // Future { promise in - // if let currentClient = state.accountsState.accounts.first(where: { $0.bareJid == chat.account }) { - // let features = state.accountsState.discoFeatures[currentClient.bareJid] ?? [] - // if features.map({ $0.xep }).contains("XEP-0313") { - // let roster = state.conversationsState.currentRoster - // let date = self.archivesRequestDate(from: messages) - // promise(.success(.xmppAction(.xmppLoadArchivedMessages(jid: currentClient.bareJid, to: roster?.bareJid, fromDate: date)))) - // } else { - // promise(.success(.info("MessageMiddleware: XEP-0313 not supported for client \(currentClient.bareJid)"))) - // } - // } else { - // promise(.success(.info("MessageMiddleware: No client found for account \(chat.account), probably some error here"))) - // } - // } - // } - // .eraseToAnyPublisher() - // } - - default: - return Empty().eraseToAnyPublisher() - } - } -} - -private extension ArchivedMessagesMiddleware { - func archivesRequestDate(from messages: [Message]) -> Date { - if let lastDate = messages.first?.date { - return lastDate - } else { - return Calendar.current.date(byAdding: .day, value: -Const.mamRequestDaysLength, to: Date()) ?? Date() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/ChatsMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/ChatsMiddleware.swift deleted file mode 100644 index 797f93d..0000000 --- a/ConversationsClassic/AppCore/Middlewares/ChatsMiddleware.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Combine - -final class ChatsMiddleware { - static let shared = ChatsMiddleware() - - func middleware(state: AppState, action: AppAction) -> AnyPublisher { - switch action { - case .databaseAction(.storedChatsLoaded(let chats)): - return Just(.chatsAction(.chatsListUpdated(chats: chats))) - .eraseToAnyPublisher() - - case .chatsAction(.startChat(accountJid: let accountJid, participantJid: let participantJid)): - return Deferred { - Future { promise in - if let exist = state.chatsState.chats.first(where: { $0.account == accountJid && $0.participant == participantJid }) { - // open existing chat - promise(.success(.chatsAction(.chatStarted(chat: exist)))) - } else { - // create new chat - promise(.success(.chatsAction(.createNewChat(accountJid: accountJid, participantJid: participantJid)))) - } - } - } - .eraseToAnyPublisher() - - case .chatsAction(.chatCreated(let chat)): - return Just(.chatsAction(.chatStarted(chat: chat))) - .eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/ConversationMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/ConversationMiddleware.swift deleted file mode 100644 index e03839d..0000000 --- a/ConversationsClassic/AppCore/Middlewares/ConversationMiddleware.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine - -final class ConversationMiddleware { - static let shared = ConversationMiddleware() - - func middleware(state: AppState, action: AppAction) -> AnyPublisher { - switch action { - case .chatsAction(.chatStarted(let chat)): - return Deferred { - Future { promise in - let roster = state.rostersState.rosters - .first { $0.bareJid == chat.account && $0.contactBareJid == chat.participant } - promise(.success(.conversationAction(.makeConversationActive(chat: chat, roster: roster)))) - } - } - .eraseToAnyPublisher() - - case .conversationAction(.makeConversationActive): - return Just(AppAction.changeFlow(.conversation)).eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift deleted file mode 100644 index c14dce8..0000000 --- a/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift +++ /dev/null @@ -1,530 +0,0 @@ -import Combine -import Foundation -import GRDB - -// swiftlint:disable:next type_body_length -final class DatabaseMiddleware { - static let shared = DatabaseMiddleware() - private let database = Database.shared - private var cancellables: Set = [] - private var conversationCancellables: Set = [] - - private init() { - // Database changes - ValueObservation - .tracking(Roster.fetchAll) - .publisher(in: database._db, scheduling: .immediate) - .sink { _ in - // Handle completion - } receiveValue: { rosters in - DispatchQueue.main.async { - store.dispatch(.databaseAction(.storedRostersLoaded(rosters: rosters))) - } - } - .store(in: &cancellables) - ValueObservation - .tracking(Chat.fetchAll) - .publisher(in: database._db, scheduling: .immediate) - .sink { _ in - // Handle completion - } receiveValue: { chats in - DispatchQueue.main.async { - store.dispatch(.databaseAction(.storedChatsLoaded(chats: chats))) - } - } - .store(in: &cancellables) - } - - // swiftlint:disable:next function_body_length cyclomatic_complexity - func middleware(state _: AppState, action: AppAction) -> AnyPublisher { - switch action { - // MARK: Accounts - case .startAction(.loadStoredAccounts): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.loadingStoredAccountsFailed))) - return - } - do { - try database._db.read { db in - let accounts = try Account.fetchAll(db) - promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts)))) - } - } catch { - promise(.success(.databaseAction(.loadingStoredAccountsFailed))) - } - } - } - } - .eraseToAnyPublisher() - - case .accountsAction(.makeAccountPermanent(let account)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAccountFailed))) - return - } - do { - try database._db.write { db in - // make permanent and store to database - var acc = account - acc.isTemp = false - try acc.insert(db) - - // Re-Fetch all accounts - let accounts = try Account.fetchAll(db) - - // Use the accounts - promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts)))) - } - } catch { - promise(.success(.databaseAction(.updateAccountFailed))) - } - } - } - } - .eraseToAnyPublisher() - - // MARK: Rosters - case .rostersAction(.markRosterAsLocallyDeleted(let ownerJID, let contactJID)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - do { - _ = try database._db.write { db in - try Roster - .filter(Column("bareJid") == ownerJID) - .filter(Column("contactBareJid") == contactJID) - .updateAll(db, Column("locallyDeleted").set(to: true)) - } - promise(.success(.info("DatabaseMiddleware: roster \(contactJID) for account \(ownerJID) marked as locally deleted"))) - } catch { - promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) - } - } - } - } - .eraseToAnyPublisher() - - case .rostersAction(.unmarkRosterAsLocallyDeleted(let ownerJID, let contactJID)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - do { - _ = try database._db.write { db in - try Roster - .filter(Column("bareJid") == ownerJID) - .filter(Column("contactBareJid") == contactJID) - .updateAll(db, Column("locallyDeleted").set(to: false)) - } - promise(.success(.info("DatabaseMiddleware: roster \(contactJID) for account \(ownerJID) unmarked as locally deleted"))) - } catch { - promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) - } - } - } - } - .eraseToAnyPublisher() - - // MARK: Chats - case .chatsAction(.createNewChat(let accountJid, let participantJid)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - do { - try database._db.write { db in - let chat = Chat( - id: UUID().uuidString, - account: accountJid, - participant: participantJid, - type: .chat - ) - try chat.insert(db) - promise(.success(.chatsAction(.chatCreated(chat: chat)))) - } - } catch { - promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError)))) - } - } - } - } - .eraseToAnyPublisher() - - // MARK: Conversation and messages - case .conversationAction(.makeConversationActive(let chat, _)): - subscribeToMessages(chat: chat) - return Empty().eraseToAnyPublisher() - - case .xmppAction(.xmppMessageReceived(let message)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - guard message.contentType != .typing, message.body != nil else { - promise(.success(.info("DatabaseMiddleware: message \(message.id) received as 'typing...' or message body is nil"))) - return - } - do { - try database._db.write { db in - try message.insert(db) - } - promise(.success(.info("DatabaseMiddleware: message \(message.id) stored in db"))) - } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))) - } - } - } - } - .eraseToAnyPublisher() - - case .conversationAction(.sendMessage(let from, let to, let body)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - do { - let message = Message( - id: UUID().uuidString, - type: .chat, - contentType: .text, - from: from, - to: to, - body: body, - subject: nil, - thread: nil, - oobUrl: nil, - date: Date(), - pending: true, - sentError: false - ) - try database._db.write { db in - try message.insert(db) - } - promise(.success(.xmppAction(.xmppMessageSent(message)))) - } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))) - } - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppMessageSendSuccess(let msgId)): - // mark message as pending false and sentError false - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == msgId) - .updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: false)) - } - promise(.success(.info("DatabaseMiddleware: message \(msgId) marked in db as sent"))) - } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppMessageSendFailed(let msgId)): - // mark message as pending false and sentError true - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == msgId) - .updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true)) - } - promise(.success(.info("DatabaseMiddleware: message \(msgId) marked in db as failed to send"))) - } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))) - } - } - } - } - .eraseToAnyPublisher() - - // MARK: Attachments - case .fileAction(.downloadAttachmentFile(let id, _)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == id) - .updateAll(db, Column("attachmentDownloadFailed").set(to: false)) - } - promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as starting downloading attachment"))) - } catch { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .fileAction(.downloadingAttachmentFileFailed(let id, _)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == id) - .updateAll(db, Column("attachmentDownloadFailed").set(to: true)) - } - promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as failed to download attachment"))) - } catch { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .fileAction(.attachmentFileDownloaded(let id, let localName)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == id) - .updateAll(db, Column("attachmentLocalName").set(to: localName), Column("attachmentDownloadFailed").set(to: false)) - } - promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as downloaded attachment"))) - } catch { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .fileAction(.attachmentThumbnailCreated(let id, let thumbnailName)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == id) - .updateAll(db, Column("attachmentThumbnailName").set(to: thumbnailName)) - } - promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as thumbnail created"))) - } catch { - promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - // MARK: Sharing - case .conversationAction(.sendMediaMessages(let from, let to, let messageIds, let localFilesNames)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - for (index, id) in messageIds.enumerated() { - let message = Message( - id: id, - type: .chat, - contentType: .attachment, - from: from, - to: to, - body: nil, - subject: nil, - thread: nil, - oobUrl: nil, - date: Date(), - pending: true, - sentError: false, - attachmentType: localFilesNames[index].attachmentType, - attachmentLocalName: localFilesNames[index] - ) - try database._db.write { db in - try message.insert(db) - } - } - promise(.success(.info("DatabaseMiddleware: messages with sharings stored in db"))) - } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .sharingAction(.retrySharing(let id)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == id) - .updateAll(db, Column("pending").set(to: true), Column("sentError").set(to: false)) - } - promise(.success(.info("DatabaseMiddleware: message \(id) with shares marked in db as pending to send"))) - } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppSharingUploadSuccess(let messageId, let remotePath)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == messageId) - .updateAll(db, Column("attachmentRemotePath").set(to: remotePath), Column("pending").set(to: false), Column("sentError").set(to: false)) - } - promise(.success(.info("DatabaseMiddleware: shared file uploaded and message \(messageId) marked in db as sent"))) - } catch { - promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppSharingUploadFailed(let messageId, _)): - return Deferred { - Future { promise in - Task(priority: .background) { [weak self] in - guard let database = self?.database else { - promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: L10n.Global.Error.genericDbError))) - ) - return - } - do { - _ = try database._db.write { db in - try Message - .filter(Column("id") == messageId) - .updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true)) - } - promise(.success(.info("DatabaseMiddleware: shared file upload failed and message \(messageId) marked in db as failed to send"))) - } catch { - promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: error.localizedDescription))) - ) - } - } - } - } - .eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} - -private extension DatabaseMiddleware { - func subscribeToMessages(chat: Chat) { - conversationCancellables = [] - ValueObservation - .tracking( - Message - .filter( - (Column("to") == chat.account && Column("from") == chat.participant) || - (Column("from") == chat.account && Column("to") == chat.participant) - ) - .order(Column("date").desc) - .fetchAll - ) - .publisher(in: database._db, scheduling: .immediate) - .sink { _ in - } receiveValue: { messages in - // messages - DispatchQueue.main.async { - store.dispatch(.conversationAction(.messagesUpdated(messages: messages))) - } - } - .store(in: &conversationCancellables) - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift deleted file mode 100644 index 8237ac2..0000000 --- a/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Combine -import Foundation -import UIKit - -final class FileMiddleware { - static let shared = FileMiddleware() - private var downloadingMessageIDs = ThreadSafeSet() - - func middleware(state _: AppState, action: AppAction) -> AnyPublisher { - switch action { - // MARK: - For incomig attachments - case .conversationAction(.messagesUpdated(let messages)): - return Deferred { - Future { [weak self] promise in - guard let wSelf = self else { - promise(.success(.info("FileMiddleware: on checking attachments/shares messages, middleware self is nil"))) - return - } - - // for incoming messages with attachments - for message in messages where message.attachmentRemotePath != nil && message.attachmentLocalPath == nil { - if wSelf.downloadingMessageIDs.contains(message.id) { - continue - } - wSelf.downloadingMessageIDs.insert(message.id) - DispatchQueue.main.async { - // swiftlint:disable:next force_unwrapping - store.dispatch(.fileAction(.downloadAttachmentFile(messageId: message.id, attachmentRemotePath: message.attachmentRemotePath!))) - } - } - - // for outgoing messages with shared attachments - for message in messages where message.attachmentLocalPath != nil && message.attachmentRemotePath == nil && message.pending { - DispatchQueue.main.async { - store.dispatch(.xmppAction(.xmppSharingTryUpload(message))) - } - } - - // for outgoing messages with shared attachments which are already uploaded - // but have no thumbnail (only for images) - for message in messages where !message.pending && !message.sentError && message.attachmentType == .image { - if message.attachmentLocalName != nil && message.attachmentRemotePath != nil && message.attachmentThumbnailName == nil { - DispatchQueue.main.async { - // swiftlint:disable:next force_unwrapping - store.dispatch(.fileAction(.createAttachmentThumbnail(messageId: message.id, localName: message.attachmentLocalName!))) - } - } - } - - promise(.success(.info("FileMiddleware: attachments/shares messages processed"))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.downloadAttachmentFile(let id, let attachmentRemotePath)): - return Deferred { - Future { promise in - let localName = "\(id)_\(UUID().uuidString)\(attachmentRemotePath.lastPathComponent)" - let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) - DownloadManager.shared.enqueueDownload(from: attachmentRemotePath, to: localUrl) { error in - DispatchQueue.main.async { - if let error { - store.dispatch(.fileAction(.downloadingAttachmentFileFailed(messageId: id, reason: error.localizedDescription))) - } else { - store.dispatch(.fileAction(.attachmentFileDownloaded(messageId: id, localName: localName))) - } - } - } - promise(.success(.info("FileMiddleware: started downloading attachment for message \(id)"))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.attachmentFileDownloaded(let id, let localName)): - return Deferred { - Future { [weak self] promise in - self?.downloadingMessageIDs.remove(id) - promise(.success(.fileAction(.createAttachmentThumbnail(messageId: id, localName: localName)))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.createAttachmentThumbnail(let id, let localName)): - return Deferred { - Future { [weak self] promise in - if let thumbnailName = FileProcessing.shared.createThumbnail(localName: localName) { - self?.downloadingMessageIDs.remove(id) - promise(.success(.fileAction(.attachmentThumbnailCreated(messageId: id, thumbnailName: thumbnailName)))) - } else { - self?.downloadingMessageIDs.remove(id) - promise(.success(.info("FileMiddleware: failed to create thumbnail from \(localName) for message \(id)"))) - } - } - } - .eraseToAnyPublisher() - - // MARK: - For outgoing sharing - case .fileAction(.fetchItemsFromGallery): - return Deferred { - Future { promise in - let items = FileProcessing.shared.fetchGallery() - promise(.success(.fileAction(.itemsFromGalleryFetched(items: items)))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.itemsFromGalleryFetched(let items)): - return Deferred { - Future { promise in - let newItems = FileProcessing.shared.fillGalleryItemsThumbnails(items: items) - promise(.success(.sharingAction(.galleryItemsUpdated(items: newItems)))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.copyGalleryItemsForUploading(let items)): - return Deferred { - Future { promise in - let ids = FileProcessing.shared.copyGalleryItemsForUploading(items: items) - promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: ids.map { $0.0 }, localNames: ids.map { $0.1 })))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.copyCameraCapturedForUploading(let media, let type)): - return Deferred { - Future { promise in - if let (id, localName) = FileProcessing.shared.copyCameraCapturedForUploading(media: media, type: type) { - promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: [id], localNames: [localName])))) - } else { - promise(.success(.info("FileMiddleware: failed to copy camera captured media for uploading"))) - } - } - } - .eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/RostersMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/RostersMiddleware.swift deleted file mode 100644 index e253589..0000000 --- a/ConversationsClassic/AppCore/Middlewares/RostersMiddleware.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Combine - -final class RostersMiddleware { - static let shared = RostersMiddleware() - - func middleware(state _: AppState, action: AppAction) -> AnyPublisher { - switch action { - case .databaseAction(.storedRostersLoaded(let rosters)): - return Just(.rostersAction(.rostersListUpdated(rosters))) - .eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/SharingMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/SharingMiddleware.swift deleted file mode 100644 index 7d6e788..0000000 --- a/ConversationsClassic/AppCore/Middlewares/SharingMiddleware.swift +++ /dev/null @@ -1,131 +0,0 @@ -import AVFoundation -import Combine -import Foundation -import Photos -import UIKit - -final class SharingMiddleware { - static let shared = SharingMiddleware() - - func middleware(state: AppState, action: AppAction) -> AnyPublisher { - switch action { - // MARK: - Camera and Gallery Access - case .sharingAction(.checkCameraAccess): - return Deferred { - Future { promise in - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .authorized: - promise(.success(.sharingAction(.setCameraAccess(true)))) - - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { granted in - promise(.success(.sharingAction(.setCameraAccess(granted)))) - } - - case .denied, .restricted: - promise(.success(.sharingAction(.setCameraAccess(false)))) - - @unknown default: - promise(.success(.sharingAction(.setCameraAccess(false)))) - } - } - } - .eraseToAnyPublisher() - - case .sharingAction(.checkGalleryAccess): - return Deferred { - Future { promise in - let status = PHPhotoLibrary.authorizationStatus() - switch status { - case .authorized, .limited: - promise(.success(.sharingAction(.setGalleryAccess(true)))) - - case .notDetermined: - PHPhotoLibrary.requestAuthorization { status in - promise(.success(.sharingAction(.setGalleryAccess(status == .authorized)))) - } - - case .denied, .restricted: - promise(.success(.sharingAction(.setGalleryAccess(false)))) - - @unknown default: - promise(.success(.sharingAction(.setGalleryAccess(false)))) - } - } - } - .eraseToAnyPublisher() - - case .fileAction(.itemsFromGalleryFetched(let items)): - return Just(.sharingAction(.galleryItemsUpdated(items: items))) - .eraseToAnyPublisher() - - // MARK: - Sharing - case .sharingAction(.shareMedia(let ids)): - return Deferred { - Future { promise in - let items = state.sharingState.galleryItems.filter { ids.contains($0.id) } - promise(.success(.fileAction(.copyGalleryItemsForUploading(items: items)))) - } - } - .eraseToAnyPublisher() - - case .fileAction(.itemsCopiedForUploading(let newMessageIds, let localNames)): - if let chat = state.conversationsState.currentChat { - return Just(.conversationAction(.sendMediaMessages( - from: chat.account, - to: chat.participant, - messagesIds: newMessageIds, - localFilesNames: localNames - ))) - .eraseToAnyPublisher() - } else { - return Empty().eraseToAnyPublisher() - } - - case .sharingAction(.cameraCaptured(let media, let type)): - return Deferred { - Future { promise in - if let (id, localName) = FileProcessing.shared.copyCameraCapturedForUploading(media: media, type: type) { - promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: [id], localNames: [localName]))) - ) - } else { - promise(.success(.info("SharingMiddleware: camera's captured file didn't copied"))) - } - } - } - .eraseToAnyPublisher() - - case .sharingAction(.shareLocation(let lat, let lon)): - if let chat = state.conversationsState.currentChat { - let msg = "geo:\(lat),\(lon)" - return Just(.conversationAction(.sendMessage(from: chat.account, to: chat.participant, body: msg))) - .eraseToAnyPublisher() - } else { - return Empty().eraseToAnyPublisher() - } - - case .sharingAction(.shareDocuments(let data, let extensions)): - return Deferred { - Future { promise in - let ids = FileProcessing.shared.copyDocumentsForUploading(data: data, extensions: extensions) - promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: ids.map { $0.0 }, localNames: ids.map { $0.1 }))) - ) - } - } - .eraseToAnyPublisher() - - case .sharingAction(.shareContact(let jid)): - if let chat = state.conversationsState.currentChat { - let msg = "contact:\(jid)" - return Just(.conversationAction(.sendMessage(from: chat.account, to: chat.participant, body: msg))) - .eraseToAnyPublisher() - } else { - return Empty().eraseToAnyPublisher() - } - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/StartMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/StartMiddleware.swift deleted file mode 100644 index 1a084b6..0000000 --- a/ConversationsClassic/AppCore/Middlewares/StartMiddleware.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Combine - -final class StartMiddleware { - static let shared = StartMiddleware() - - func middleware(state: AppState, action: AppAction) -> AnyPublisher { - switch action { - case .accountsAction(.accountsListUpdated(let accounts)): - if accounts.isEmpty { - if state.currentFlow == .start { - return Just(.startAction(.goTo(.welcomeScreen))) - .eraseToAnyPublisher() - } else { - return Empty().eraseToAnyPublisher() - } - } else { - if state.currentFlow == .accounts, state.accountsState.navigation == .addAccount { - return Just(.changeFlow(.chats)) - .eraseToAnyPublisher() - } else if state.currentFlow == .start { - return Just(.changeFlow(.chats)) - .eraseToAnyPublisher() - } else { - return Empty().eraseToAnyPublisher() - } - } - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Middlewares/XMPPMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/XMPPMiddleware.swift deleted file mode 100644 index 4f98a0b..0000000 --- a/ConversationsClassic/AppCore/Middlewares/XMPPMiddleware.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Combine -import Foundation -import Martin - -final class XMPPMiddleware { - static let shared = XMPPMiddleware() - private let service = XMPPService(manager: Database.shared) - private var cancellables: Set = [] - private var uploadingMessageIDs = ThreadSafeSet() - - private init() { - service.clientState.sink { client, state in - let jid = client.userBareJid.stringValue - let status = ConnectionStatus.from(state) - let action = AppAction.xmppAction(.clientConnectionChanged(jid: jid, state: status)) - DispatchQueue.main.async { - store.dispatch(action) - } - } - .store(in: &cancellables) - - service.clientMessages.sink { _, martinMessage in - guard let message = Message.map(martinMessage) else { return } - DispatchQueue.main.async { - store.dispatch(.xmppAction(.xmppMessageReceived(message))) - } - } - .store(in: &cancellables) - - service.clientFeatures.sink { client, features in - let jid = client.userBareJid.stringValue - DispatchQueue.main.async { - store.dispatch(.xmppAction(.serverFeaturesLoaded(jid: jid, features: features))) - } - } - .store(in: &cancellables) - } - - func middleware(state: AppState, action: AppAction) -> AnyPublisher { - switch action { - case .accountsAction(.tryAddAccountWithCredentials): - return Deferred { - Future { [weak self] promise in - self?.service.updateClients(for: state.accountsState.accounts) - promise(.success(.info("XMPPMiddleware: clients updated in XMPP service"))) - } - } - .eraseToAnyPublisher() - - case .accountsAction(.addAccountError): - return Deferred { - Future { [weak self] promise in - self?.service.updateClients(for: state.accountsState.accounts) - promise(.success(.info("XMPPMiddleware: clients updated in XMPP service"))) - } - } - .eraseToAnyPublisher() - - case .databaseAction(.storedAccountsLoaded(let accounts)): - return Deferred { - Future { [weak self] promise in - self?.service.updateClients(for: accounts.filter { $0.isActive && !$0.isTemp }) - promise(.success(.info("XMPPMiddleware: clients updated in XMPP service"))) - } - } - .eraseToAnyPublisher() - - case .rostersAction(.addRoster(let ownerJID, let contactJID, let name, let groups)): - return Deferred { - Future { [weak self] promise in - guard let service = self?.service, let client = service.clients.first(where: { $0.connectionConfiguration.userJid.stringValue == ownerJID }) else { - return promise(.success(.rostersAction(.addRosterError(reason: XMPPError.item_not_found.localizedDescription)))) - } - let module = client.modulesManager.module(RosterModule.self) - module.addItem(jid: JID(contactJID), name: name, groups: groups, completionHandler: { result in - switch result { - case .success: - promise(.success(.rostersAction(.addRosterDone(jid: contactJID)))) - - case .failure(let error): - promise(.success(.rostersAction(.addRosterError(reason: error.localizedDescription)))) - } - }) - } - } - .eraseToAnyPublisher() - - case .rostersAction(.deleteRoster(let ownerJID, let contactJID)): - return Deferred { - Future { [weak self] promise in - guard let service = self?.service, let client = service.clients.first(where: { $0.connectionConfiguration.userJid.stringValue == ownerJID }) else { - return promise(.success(.rostersAction(.rosterDeletingFailed(reason: XMPPError.item_not_found.localizedDescription)))) - } - let module = client.modulesManager.module(RosterModule.self) - module.removeItem(jid: JID(contactJID), completionHandler: { result in - switch result { - case .success: - promise(.success(.info("XMPPMiddleware: roster \(contactJID) deleted from \(ownerJID)"))) - - case .failure(let error): - promise(.success(.rostersAction(.rosterDeletingFailed(reason: error.localizedDescription)))) - } - }) - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppMessageSent(let message)): - return Deferred { - Future { [weak self] promise in - DispatchQueue.global().async { - self?.service.sendMessage(message: message) { done in - if done { - promise(.success(.xmppAction(.xmppMessageSendSuccess(msgId: message.id)))) - } else { - promise(.success(.xmppAction(.xmppMessageSendFailed(msgId: message.id)))) - } - } - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppSharingTryUpload(let message)): - return Deferred { - Future { [weak self] promise in - if self?.uploadingMessageIDs.contains(message.id) ?? false { - return promise(.success(.info("XMPPMiddleware: attachment in message \(message.id) is already in uploading process"))) - } else { - self?.uploadingMessageIDs.insert(message.id) - DispatchQueue.global().async { - self?.service.uploadAttachment(message: message) { error, remotePath in - self?.uploadingMessageIDs.remove(message.id) - if let error { - promise(.success(.xmppAction(.xmppSharingUploadFailed(msgId: message.id, reason: error.localizedDescription)))) - } else { - promise(.success(.xmppAction(.xmppSharingUploadSuccess(msgId: message.id, attachmentRemotePath: remotePath)))) - } - } - } - } - } - } - .eraseToAnyPublisher() - - case .xmppAction(.xmppLoadArchivedMessages(let jid, let to, let fromDate)): - return Empty().eraseToAnyPublisher() - // return Deferred { - // Future { [weak self] promise in - // self?.service.requestArchivedMessages(jid: jid, to: to, fromDate: fromDate) - // promise(.success(.conversationAction(.setArchivedMessagesRequested))) - // } - // } - // .eraseToAnyPublisher() - - default: - return Empty().eraseToAnyPublisher() - } - } -} diff --git a/ConversationsClassic/AppCore/Models/Account.swift b/ConversationsClassic/AppCore/Models/Account.swift deleted file mode 100644 index 264d195..0000000 --- a/ConversationsClassic/AppCore/Models/Account.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import GRDB -import Martin -import SwiftUI - -// MARK: - Account -struct Account: DBStorable { - var bareJid: String - var pass: String - var isActive: Bool - var isTemp: Bool // account which is added by user, but not yet logged in - var id: String { bareJid } -} - -extension Account: UniversalInputSelectionElement { - var text: String? { bareJid } - var icon: Image? { nil } -} - -extension Account { - static let databaseTableName = "accounts" -} diff --git a/ConversationsClassic/AppCore/Models/Channel.swift b/ConversationsClassic/AppCore/Models/Channel.swift deleted file mode 100644 index 9c8275f..0000000 --- a/ConversationsClassic/AppCore/Models/Channel.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation -import GRDB -import Martin -import SwiftUI - -// MARK: - Account -struct Channel: DBStorable { - var id: String - var account: String - var channel: String -} - -extension Channel { - static let channelTableName = "channels" -} diff --git a/ConversationsClassic/AppCore/Models/Chat.swift b/ConversationsClassic/AppCore/Models/Chat.swift deleted file mode 100644 index 58fc273..0000000 --- a/ConversationsClassic/AppCore/Models/Chat.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation -import GRDB - -enum ConversationType: Int, Codable, DatabaseValueConvertible { - case chat = 0 - case room = 1 - case channel = 2 -} - -struct Chat: DBStorable { - static let databaseTableName = "chats" - - var id: String - var account: String - var participant: String - var type: ConversationType -} - -extension Chat: Equatable {} diff --git a/ConversationsClassic/AppCore/Models/ConnectionStatus.swift b/ConversationsClassic/AppCore/Models/ConnectionStatus.swift deleted file mode 100644 index fab36aa..0000000 --- a/ConversationsClassic/AppCore/Models/ConnectionStatus.swift +++ /dev/null @@ -1,27 +0,0 @@ -// This struct is simpliest variant of Martin's Client State. -// Just for more comfortable using in App State -import Foundation -import Martin - -enum ConnectionStatus: Stateable { - case connecting - case connected(resumed: Bool = false) - case disconnecting - case disconnected(reason: String) - - static func from(_ state: XMPPClient.State) -> ConnectionStatus { - switch state { - case .connecting: - return .connecting - - case .connected(let resumed): - return .connected(resumed: resumed) - - case .disconnecting: - return .disconnecting - - case .disconnected(let reason): - return .disconnected(reason: reason.localizedDescription) - } - } -} diff --git a/ConversationsClassic/AppCore/Models/Message.swift b/ConversationsClassic/AppCore/Models/Message.swift deleted file mode 100644 index c5fd963..0000000 --- a/ConversationsClassic/AppCore/Models/Message.swift +++ /dev/null @@ -1,128 +0,0 @@ -import Foundation -import GRDB -import Martin - -enum MessageType: String, Codable, DatabaseValueConvertible { - case chat - case groupchat - case error -} - -enum MessageAttachmentType: Int, Stateable, DatabaseValueConvertible { - case movie = 0 - case image = 1 - case audio = 2 - case file = 3 -} - -enum MessageContentType: String, Codable, DatabaseValueConvertible { - case text - case typing - case invite - case attachment -} - -struct Message: DBStorable, Equatable { - static let databaseTableName = "messages" - - let id: String - let type: MessageType - let contentType: MessageContentType - - let from: String - let to: String? - - let body: String? - let subject: String? - let thread: String? - let oobUrl: String? - - let date: Date - let pending: Bool - let sentError: Bool - - var attachmentType: MessageAttachmentType? - var attachmentLocalName: String? - var attachmentRemotePath: URL? - var attachmentThumbnailName: String? - var attachmentDownloadFailed: Bool = false -} - -extension Message { - // Universal mapping from Martin's Message to App Message - static func map(_ martinMessage: Martin.Message) -> Message? { - #if DEBUG - print("---") - print("Message received: \(martinMessage)") - print("---") - #endif - - // Check that the message type is supported - let chatTypes: [StanzaType] = [.chat, .groupchat] - guard let mType = martinMessage.type, chatTypes.contains(mType) else { - #if DEBUG - print("Unsupported message type: \(martinMessage.type?.rawValue ?? "nil")") - #endif - return nil - } - - // Type - let type = MessageType(rawValue: martinMessage.type?.rawValue ?? "") ?? .chat - - // Content type - var contentType: MessageContentType = .text - if martinMessage.oob != nil { - contentType = .attachment - } else if martinMessage.hints.contains(.noStore) { - contentType = .typing - } - - // From/To - let from = martinMessage.from?.bareJid.stringValue ?? "" - let to = martinMessage.to?.bareJid.stringValue - - // Extract date or set current - var date = Date() - if let timestampStr = martinMessage.attribute("archived_date"), let timeInterval = TimeInterval(timestampStr) { - date = Date(timeIntervalSince1970: timeInterval) - } - - // Msg - var msg = Message( - id: martinMessage.id ?? UUID().uuidString, - type: type, - contentType: contentType, - from: from, - to: to, - body: martinMessage.body, - subject: martinMessage.subject, - thread: martinMessage.thread, - oobUrl: martinMessage.oob, - date: date, - pending: false, - sentError: false, - attachmentType: nil, - attachmentLocalName: nil, - attachmentRemotePath: nil, - attachmentThumbnailName: nil, - attachmentDownloadFailed: false - ) - if let oob = martinMessage.oob { - msg.attachmentType = oob.attachmentType - msg.attachmentRemotePath = URL(string: oob) - } - return msg - } -} - -extension Message { - var attachmentLocalPath: URL? { - guard let attachmentLocalName = attachmentLocalName else { return nil } - return FileProcessing.fileFolder.appendingPathComponent(attachmentLocalName) - } - - var attachmentThumbnailPath: URL? { - guard let attachmentThumbnailName = attachmentThumbnailName else { return nil } - return FileProcessing.fileFolder.appendingPathComponent(attachmentThumbnailName) - } -} diff --git a/ConversationsClassic/AppCore/Models/Room.swift b/ConversationsClassic/AppCore/Models/Room.swift deleted file mode 100644 index 583fd8b..0000000 --- a/ConversationsClassic/AppCore/Models/Room.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation -import GRDB -import Martin -import SwiftUI - -// MARK: - Account -struct Room: DBStorable { - var id: String - var account: String - var nickname: String - var password: String? -} - -extension Room { - static let roomTableName = "rooms" -} diff --git a/ConversationsClassic/AppCore/Models/ServerFeature.swift b/ConversationsClassic/AppCore/Models/ServerFeature.swift deleted file mode 100644 index 8ee3663..0000000 --- a/ConversationsClassic/AppCore/Models/ServerFeature.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -struct ServerFeature: Stateable, Identifiable { - let xep: String - let name: String - let xmppId: String? - let description: String? - - var id: String { xep } -} diff --git a/ConversationsClassic/AppCore/Reducers/AccountsReducer.swift b/ConversationsClassic/AppCore/Reducers/AccountsReducer.swift deleted file mode 100644 index f5a7c56..0000000 --- a/ConversationsClassic/AppCore/Reducers/AccountsReducer.swift +++ /dev/null @@ -1,25 +0,0 @@ -extension AccountsState { - static func reducer(state: inout AccountsState, action: AccountsAction) { - switch action { - case .accountsListUpdated(let accounts): - state.accounts = accounts - - case .goTo(let navigation): - state.navigation = navigation - - case .tryAddAccountWithCredentials(let login, let password): - let account = Account(bareJid: login, pass: password, isActive: true, isTemp: true) - state.accounts.append(account) - - case .addAccountError(let jid, let reason): - state.accounts = state.accounts.filter { $0.bareJid != jid } - state.addAccountError = reason - - case .clientServerFeaturesUpdated(let jid, let features): - state.discoFeatures[jid] = features - - default: - break - } - } -} diff --git a/ConversationsClassic/AppCore/Reducers/AppReducer.swift b/ConversationsClassic/AppCore/Reducers/AppReducer.swift deleted file mode 100644 index ff67556..0000000 --- a/ConversationsClassic/AppCore/Reducers/AppReducer.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -extension AppState { - static func reducer(state: inout AppState, action: AppAction) { - switch action { - case .flushState: - state = AppState() - - case .changeFlow(let flow): - state.previousFlow = state.currentFlow - state.currentFlow = flow - - case .startAction(let action): - StartState.reducer(state: &state.startState, action: action) - - case .databaseAction, .xmppAction, .fileAction, .info: - break // this actions are processed by other middlewares - - case .accountsAction(let action): - AccountsState.reducer(state: &state.accountsState, action: action) - - case .rostersAction(let action): - RostersState.reducer(state: &state.rostersState, action: action) - - case .chatsAction(let action): - ChatsState.reducer(state: &state.chatsState, action: action) - - case .conversationAction(let action): - ConversationState.reducer(state: &state.conversationsState, action: action) - - case .sharingAction(let action): - SharingState.reducer(state: &state.sharingState, action: action) - } - } -} diff --git a/ConversationsClassic/AppCore/Reducers/ChatsReducer.swift b/ConversationsClassic/AppCore/Reducers/ChatsReducer.swift deleted file mode 100644 index 393c61c..0000000 --- a/ConversationsClassic/AppCore/Reducers/ChatsReducer.swift +++ /dev/null @@ -1,14 +0,0 @@ -extension ChatsState { - static func reducer(state: inout ChatsState, action: ChatsAction) { - switch action { - case .chatsListUpdated(let chats): - state.chats = chats - - case .chatStarted(let chat): - state.currentChat = chat - - default: - break - } - } -} diff --git a/ConversationsClassic/AppCore/Reducers/ConversationReducer.swift b/ConversationsClassic/AppCore/Reducers/ConversationReducer.swift deleted file mode 100644 index cc76c62..0000000 --- a/ConversationsClassic/AppCore/Reducers/ConversationReducer.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI - -extension ConversationState { - static func reducer(state: inout ConversationState, action: ConversationAction) { - switch action { - case .makeConversationActive(let chat, let roster): - state.currentChat = chat - state.currentRoster = roster - - case .messagesUpdated(let messages): - state.currentMessages = messages - - case .setReplyText(let text): - if text.isEmpty { - state.replyText = "" - } else { - state.replyText = text.makeReply - } - - default: - break - } - } -} diff --git a/ConversationsClassic/AppCore/Reducers/RostersReducer.swift b/ConversationsClassic/AppCore/Reducers/RostersReducer.swift deleted file mode 100644 index 076559e..0000000 --- a/ConversationsClassic/AppCore/Reducers/RostersReducer.swift +++ /dev/null @@ -1,25 +0,0 @@ -extension RostersState { - static func reducer(state: inout RostersState, action: RostersAction) { - switch action { - case .addRosterDone(let jid): - state.newAddedRosterJid = jid - state.newAddedRosterError = nil - - case .addRosterError(let reason): - state.newAddedRosterJid = nil - state.newAddedRosterError = reason - - case .rostersListUpdated(let rosters): - state.rosters = rosters - - case .markRosterAsLocallyDeleted, .deleteRoster: - state.deleteRosterError = nil - - case .rosterDeletingFailed(let reson): - state.deleteRosterError = reson - - default: - break - } - } -} diff --git a/ConversationsClassic/AppCore/Reducers/SharingReducer.swift b/ConversationsClassic/AppCore/Reducers/SharingReducer.swift deleted file mode 100644 index 3b66fc9..0000000 --- a/ConversationsClassic/AppCore/Reducers/SharingReducer.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -extension SharingState { - static func reducer(state: inout SharingState, action: SharingAction) { - switch action { - case .showSharing(let shown): - state.sharingShown = shown - - case .setCameraAccess(let granted): - state.isCameraAccessGranted = granted - - case .setGalleryAccess(let granted): - state.isGalleryAccessGranted = granted - - case .galleryItemsUpdated(let items): - state.galleryItems = items - - default: - break - } - } -} diff --git a/ConversationsClassic/AppCore/Reducers/StartReducer.swift b/ConversationsClassic/AppCore/Reducers/StartReducer.swift deleted file mode 100644 index 76539f6..0000000 --- a/ConversationsClassic/AppCore/Reducers/StartReducer.swift +++ /dev/null @@ -1,11 +0,0 @@ -extension StartState { - static func reducer(state: inout StartState, action: StartAction) { - switch action { - case .loadStoredAccounts: - break - - case .goTo(let navigation): - state.navigation = navigation - } - } -} diff --git a/ConversationsClassic/AppCore/State/AccountsState.swift b/ConversationsClassic/AppCore/State/AccountsState.swift deleted file mode 100644 index 4e6cdb6..0000000 --- a/ConversationsClassic/AppCore/State/AccountsState.swift +++ /dev/null @@ -1,20 +0,0 @@ -enum AccountNavigationState: Stateable { - case addAccount -} - -struct AccountsState: Stateable { - var navigation: AccountNavigationState - var accounts: [Account] - var discoFeatures: [String: [ServerFeature]] - - var addAccountError: String? -} - -// MARK: Init -extension AccountsState { - init() { - navigation = .addAccount - accounts = [] - discoFeatures = [:] - } -} diff --git a/ConversationsClassic/AppCore/State/AppState.swift b/ConversationsClassic/AppCore/State/AppState.swift deleted file mode 100644 index 56f589f..0000000 --- a/ConversationsClassic/AppCore/State/AppState.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -enum AppFlow: Codable { - case start - case accounts - case chats - case contacts - case settings - case conversation -} - -struct AppState: Stateable { - var appVersion: String - var previousFlow: AppFlow - var currentFlow: AppFlow - - var startState: StartState - var accountsState: AccountsState - var rostersState: RostersState - var chatsState: ChatsState - var conversationsState: ConversationState - var sharingState: SharingState -} - -// MARK: Init -extension AppState { - init() { - appVersion = Const.appVersion - previousFlow = .start - currentFlow = .start - - startState = StartState() - accountsState = AccountsState() - rostersState = RostersState() - chatsState = ChatsState() - conversationsState = ConversationState() - sharingState = SharingState() - } -} diff --git a/ConversationsClassic/AppCore/State/ChatsState.swift b/ConversationsClassic/AppCore/State/ChatsState.swift deleted file mode 100644 index 469aef8..0000000 --- a/ConversationsClassic/AppCore/State/ChatsState.swift +++ /dev/null @@ -1,11 +0,0 @@ -struct ChatsState: Stateable { - var chats: [Chat] - var currentChat: Chat? -} - -// MARK: Init -extension ChatsState { - init() { - chats = [] - } -} diff --git a/ConversationsClassic/AppCore/State/ConversationState.swift b/ConversationsClassic/AppCore/State/ConversationState.swift deleted file mode 100644 index 9267bbd..0000000 --- a/ConversationsClassic/AppCore/State/ConversationState.swift +++ /dev/null @@ -1,15 +0,0 @@ -struct ConversationState: Stateable { - var currentChat: Chat? - var currentRoster: Roster? - var currentMessages: [Message] - - var replyText: String -} - -// MARK: Init -extension ConversationState { - init() { - currentMessages = [] - replyText = "" - } -} diff --git a/ConversationsClassic/AppCore/State/RostersState.swift b/ConversationsClassic/AppCore/State/RostersState.swift deleted file mode 100644 index bc42a70..0000000 --- a/ConversationsClassic/AppCore/State/RostersState.swift +++ /dev/null @@ -1,15 +0,0 @@ -struct RostersState: Stateable { - var rosters: [Roster] - - var newAddedRosterJid: String? - var newAddedRosterError: String? - - var deleteRosterError: String? -} - -// MARK: Init -extension RostersState { - init() { - rosters = [] - } -} diff --git a/ConversationsClassic/AppCore/State/SharingState.swift b/ConversationsClassic/AppCore/State/SharingState.swift deleted file mode 100644 index d19c489..0000000 --- a/ConversationsClassic/AppCore/State/SharingState.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -enum SharingCameraMediaType: Stateable { - case video - case photo -} - -struct SharingGalleryItem: Stateable, Identifiable { - var id: String - var type: SharingCameraMediaType - var thumbnail: Data? - var duration: String? -} - -struct SharingState: Stateable { - var sharingShown: Bool - var isCameraAccessGranted: Bool - var isGalleryAccessGranted: Bool - - var galleryItems: [SharingGalleryItem] -} - -// MARK: Init -extension SharingState { - init() { - sharingShown = false - isCameraAccessGranted = false - isGalleryAccessGranted = false - - galleryItems = [] - } -} diff --git a/ConversationsClassic/AppCore/State/StartState.swift b/ConversationsClassic/AppCore/State/StartState.swift deleted file mode 100644 index 15bb709..0000000 --- a/ConversationsClassic/AppCore/State/StartState.swift +++ /dev/null @@ -1,15 +0,0 @@ -enum StartNavigationState: Stateable { - case startScreen - case welcomeScreen -} - -struct StartState: Stateable { - var navigation: StartNavigationState -} - -// MARK: Init -extension StartState { - init() { - navigation = .startScreen - } -} diff --git a/ConversationsClassic/AppCore/XMPP/XMPPService.swift b/ConversationsClassic/AppCore/XMPP/XMPPService.swift deleted file mode 100644 index f9109ff..0000000 --- a/ConversationsClassic/AppCore/XMPP/XMPPService.swift +++ /dev/null @@ -1,266 +0,0 @@ -import Combine -import Foundation -import GRDB -import Martin - -protocol MartinsManager: Martin.RosterManager & Martin.ChatManager & Martin.ChannelManager & Martin.RoomManager {} - -final class XMPPService: ObservableObject { - private let manager: MartinsManager - - private let clientStatePublisher = PassthroughSubject<(XMPPClient, XMPPClient.State), Never>() - private var clientStateCancellables: Set = [] - - private let clientMessagesPublisher = PassthroughSubject<(XMPPClient, Martin.Message), Never>() - private var clientMessagesCancellables: Set = [] - - private let clientFeaturesPublisher = PassthroughSubject<(XMPPClient, [String]), Never>() - private var clientFeaturesCancellables: Set = [] - - @Published private(set) var clients: [XMPPClient] = [] - var clientState: AnyPublisher<(XMPPClient, XMPPClient.State), Never> { - clientStatePublisher.eraseToAnyPublisher() - } - - var clientMessages: AnyPublisher<(XMPPClient, Martin.Message), Never> { - clientMessagesPublisher.eraseToAnyPublisher() - } - - var clientFeatures: AnyPublisher<(XMPPClient, [String]), Never> { - clientFeaturesPublisher.eraseToAnyPublisher() - } - - init(manager: MartinsManager) { - self.manager = manager - } - - func updateClients(for accounts: [Account]) { - // get simple diff - let forAdd = accounts - .filter { !self.clients.map { $0.connectionConfiguration.userJid.stringValue }.contains($0.bareJid) } - let forRemove = clients - .map { $0.connectionConfiguration.userJid.stringValue } - .filter { !accounts.map { $0.bareJid }.contains($0) } - - // init and add clients - for account in forAdd { - // add client - let client = makeClient(for: account, with: manager) - clients.append(client) - - // subscribe to client state - client.$state - .sink { [weak self] state in - self?.clientStatePublisher.send((client, state)) - } - .store(in: &clientStateCancellables) - - // subscribe to client server features - client.module(DiscoveryModule.self).$serverDiscoResult - .sink { [weak self] disco in - self?.clientFeaturesPublisher.send((client, disco.features)) - } - .store(in: &clientFeaturesCancellables) - - // subscribe to client messages - client.module(MessageModule.self).messagesPublisher - .sink { [weak self] message in - self?.clientMessagesPublisher.send((client, message.message)) - } - .store(in: &clientMessagesCancellables) - - // subscribe to carbons - client.module(MessageCarbonsModule.self).carbonsPublisher - .sink { [weak self] carbon in - self?.clientMessagesPublisher.send((client, carbon.message)) - } - .store(in: &clientMessagesCancellables) - - // subscribe to archived messages - // client.module(.mam).archivedMessagesPublisher - // .sink(receiveValue: { [weak self] archived in - // let message = archived.message - // message.attribute("archived_date", newValue: "\(archived.timestamp.timeIntervalSince1970)") - // self?.clientMessagesPublisher.send((client, message)) - // }) - // .store(in: &clientMessagesCancellables) - - // enable carbons if available - client.module(.messageCarbons).$isAvailable.filter { $0 } - .sink(receiveValue: { [weak client] _ in - client?.module(.messageCarbons).enable() - }) - .store(in: &clientMessagesCancellables) - - // finally, do login - client.login() - } - - // remove clients - for jid in forRemove { - deinitClient(jid: jid) - } - } - - private func makeClient(for account: Account, with manager: MartinsManager) -> XMPPClient { - let client = XMPPClient() - - // register modules - // core modules RFC 6120 - client.modulesManager.register(StreamFeaturesModule()) - client.modulesManager.register(SaslModule()) - client.modulesManager.register(AuthModule()) - client.modulesManager.register(SessionEstablishmentModule()) - client.modulesManager.register(ResourceBinderModule()) - client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName))) - - // messaging modules RFC 6121 - client.modulesManager.register(RosterModule(rosterManager: manager)) - client.modulesManager.register(PresenceModule()) - - client.modulesManager.register(PubSubModule()) - client.modulesManager.register(MessageModule(chatManager: manager)) - client.modulesManager.register(MessageArchiveManagementModule()) - - client.modulesManager.register(MessageCarbonsModule()) - - // file transfer modules - client.modulesManager.register(HttpFileUploadModule()) - - // extensions - client.modulesManager.register(SoftwareVersionModule()) - client.modulesManager.register(PingModule()) - client.connectionConfiguration.userJid = .init(account.bareJid) - client.connectionConfiguration.credentials = .password(password: account.pass) - - // group chats - client.modulesManager.register(MucModule(roomManager: manager)) - - // channels - // client.modulesManager.register(MixModule(channelManager: manager)) - - // add client to clients - return client - } - - func deinitClient(jid: String) { - if let index = clients.firstIndex(where: { $0.connectionConfiguration.userJid.stringValue == jid }) { - let client = clients.remove(at: index) - _ = client.disconnect() - } - } - - func getClient(for jid: String) -> XMPPClient? { - clients.first { $0.connectionConfiguration.userJid.stringValue == jid } - } - - func sendMessage(message: Message, completion: @escaping (Bool) -> Void) { - guard let client = getClient(for: message.from), let to = message.to else { - completion(false) - return - } - guard let chat = client.module(MessageModule.self).chatManager.chat(for: client.context, with: BareJID(to)) else { - completion(false) - return - } - - let msg = chat.createMessage(text: message.body ?? "??", id: message.id) - chat.send(message: msg) { res in - switch res { - case .success: - completion(true) - - case .failure: - completion(false) - } - } - } - - func uploadAttachment(message: Message, completion: @escaping (Error?, String) -> Void) { - guard let client = getClient(for: message.from), let to = message.to else { - completion(XMPPError.bad_request("No such client"), "") - return - } - guard let fileName = message.attachmentLocalName else { - completion(XMPPError.bad_request("No such file"), "") - return - } - let url = FileProcessing.fileFolder.appendingPathComponent(fileName) - guard let data = try? Data(contentsOf: url) else { - completion(XMPPError.bad_request("No such file"), "") - return - } - guard let chat = client.module(MessageModule.self).chatManager.chat(for: client.context, with: BareJID(to)) else { - completion(XMPPError.bad_request("No such chat"), "") - return - } - - let httpModule = client.module(HttpFileUploadModule.self) - httpModule.findHttpUploadComponent { res in - switch res { - case .success(let components): - guard let component = components.first(where: { $0.maxSize > data.count }) else { - completion(XMPPError.bad_request("File too big"), "") - return - } - httpModule.requestUploadSlot(componentJid: component.jid, filename: fileName, size: data.count, contentType: url.mimeType) { res in - switch res { - case .success(let slot): - var request = URLRequest(url: slot.putUri) - for (key, value) in slot.putHeaders { - request.addValue(value, forHTTPHeaderField: key) - } - request.httpMethod = "PUT" - request.httpBody = data - request.addValue(String(data.count), forHTTPHeaderField: "Content-Length") - request.addValue(url.mimeType, forHTTPHeaderField: "Content-Type") - let session = URLSession(configuration: URLSessionConfiguration.default) - session.dataTask(with: request) { _, response, error in - let code = (response as? HTTPURLResponse)?.statusCode ?? 500 - guard error == nil, code == 200 || code == 201 else { - completion(XMPPError.bad_request("Upload failed"), "") - return - } - if code == 200 { - completion(XMPPError.bad_request("Invalid response code"), "") - } else { - let mesg = chat.createMessage(text: slot.getUri.absoluteString, id: message.id) - mesg.oob = slot.getUri.absoluteString - chat.send(message: mesg) { res in - switch res { - case .success: - completion(nil, slot.getUri.absoluteString) - - case .failure: - completion(XMPPError.bad_request("File uploaded, but message sent failed"), slot.getUri.absoluteString) - } - } - } - }.resume() - - case .failure(let error): - completion(error, "") - } - } - - case .failure(let error): - completion(error, "") - } - } - } - - func requestArchivedMessages(jid: String, to: String?, fromDate: Date) { - guard let client = getClient(for: jid) else { - return - } - client.module(.mam).queryItems(componentJid: JID(jid), with: JID(to), start: fromDate, end: Date(), queryId: UUID().uuidString) { result in - switch result { - case .success(let response): - print("MAM response: \(response)") - - case .failure(let error): - print("MAM error: \(error)") - } - } - } -} diff --git a/ConversationsClassic/AppData/AppError.swift b/ConversationsClassic/AppData/AppError.swift new file mode 100644 index 0000000..5f5677f --- /dev/null +++ b/ConversationsClassic/AppData/AppError.swift @@ -0,0 +1,11 @@ +enum AppError: Error { + case clientNotFound + case rosterNotFound + case imageNotFound + case videoNotFound + case noData + case fileTooBig + case invalidContentType + case invalidLocalName + case featureNotSupported +} diff --git a/ConversationsClassic/AppData/Client/Client+MartinCarbons.swift b/ConversationsClassic/AppData/Client/Client+MartinCarbons.swift new file mode 100644 index 0000000..1e1b0fb --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client+MartinCarbons.swift @@ -0,0 +1,47 @@ +import Combine +import Foundation +import GRDB +import Martin + +final class ClientMartinCarbonsManager { + private var cancellables: Set = [] + + init(_ xmppConnection: XMPPClient) { + // subscribe to carbons + xmppConnection.module(MessageCarbonsModule.self).carbonsPublisher + .sink { [weak self] carbon in + self?.handleMessage(carbon) + } + .store(in: &cancellables) + + // enable carbons if available + xmppConnection.module(.messageCarbons).$isAvailable.filter { $0 } + .sink(receiveValue: { [weak xmppConnection] _ in + xmppConnection?.module(.messageCarbons).enable() + }) + .store(in: &cancellables) + } + + private func handleMessage(_ received: Martin.MessageCarbonsModule.CarbonReceived) { + let message = received.message + let action = received.action + let onJid = received.jid + #if DEBUG + print("---") + print("Carbons message received: \(message)") + print("Action: \(action)") + print("On JID: \(onJid)") + print("---") + #endif + + if let msg = Message.map(message, context: nil) { + Task { + do { + try await msg.save() + } catch { + logIt(.error, "Error saving message: \(error)") + } + } + } + } +} diff --git a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinChatManager.swift b/ConversationsClassic/AppData/Client/Client+MartinChats.swift similarity index 84% rename from ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinChatManager.swift rename to ConversationsClassic/AppData/Client/Client+MartinChats.swift index 6f795f9..20befc7 100644 --- a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinChatManager.swift +++ b/ConversationsClassic/AppData/Client/Client+MartinChats.swift @@ -2,10 +2,10 @@ import Foundation import GRDB import Martin -extension Database: Martin.ChatManager { +final class ClientMartinChatsManager: Martin.ChatManager { func chats(for context: Martin.Context) -> [any Martin.ChatProtocol] { do { - let chats: [Chat] = try _db.read { db in + let chats: [Chat] = try Database.shared.dbQueue.read { db in try Chat.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db) } return chats.map { chat in @@ -19,7 +19,7 @@ extension Database: Martin.ChatManager { func chat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? { do { - let chat: Chat? = try _db.read { db in + let chat: Chat? = try Database.shared.dbQueue.read { db in try Chat .filter(Column("account") == context.userBareJid.stringValue) .filter(Column("participant") == with.stringValue) @@ -38,7 +38,7 @@ extension Database: Martin.ChatManager { func createChat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? { do { - let chat: Chat? = try _db.read { db in + let chat: Chat? = try Database.shared.dbQueue.read { db in try Chat .filter(Column("account") == context.userBareJid.stringValue) .filter(Column("participant") == with.stringValue) @@ -53,7 +53,7 @@ extension Database: Martin.ChatManager { participant: with.stringValue, type: .chat ) - try _db.write { db in + try Database.shared.dbQueue.write { db in try chat.save(db) } return Martin.ChatBase(context: context, jid: with) @@ -69,4 +69,7 @@ extension Database: Martin.ChatManager { print("Closing chat: \(chat)") return false } + + func initialize(context _: Martin.Context) {} + func deinitialize(context _: Martin.Context) {} } diff --git a/ConversationsClassic/AppData/Client/Client+MartinDisco.swift b/ConversationsClassic/AppData/Client/Client+MartinDisco.swift new file mode 100644 index 0000000..d2f5e52 --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client+MartinDisco.swift @@ -0,0 +1,23 @@ +import Combine +import Foundation +import GRDB +import Martin + +final class ClientMartinDiscoManager { + private(set) var features: [ServerFeature] = [] + private var cancellables: Set = [] + + init(_ xmppConnection: XMPPClient) { + // subscribe to client server features + xmppConnection.module(DiscoveryModule.self).$serverDiscoResult + .sink { [weak self] disco in + let allFeatures = ServerFeature.allFeatures + let features = disco.features + .compactMap { featureId in + allFeatures.first(where: { $0.xmppId == featureId }) + } + self?.features = features + } + .store(in: &cancellables) + } +} diff --git a/ConversationsClassic/AppData/Client/Client+MartinMAM.swift b/ConversationsClassic/AppData/Client/Client+MartinMAM.swift new file mode 100644 index 0000000..a82336e --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client+MartinMAM.swift @@ -0,0 +1,79 @@ +import Combine +import Foundation +import GRDB +import Martin + +private typealias ArchMsg = Martin.MessageArchiveManagementModule.ArchivedMessageReceived + +final class ClientMartinMAM { + private var cancellables: Set = [] + private var processor = ArchiveMessageProcessor() + + init(_ xmppConnection: XMPPClient) { + // subscribe to archived messages + xmppConnection.module(.mam).archivedMessagesPublisher + .sink(receiveValue: { [weak self] archived in + guard let self = self else { return } + Task { + await self.processor.append(archived) + } + }) + .store(in: &cancellables) + } +} + +private actor ArchiveMessageProcessor { + private var accumulator: [ArchMsg] = [] + + init() { + Task { + while true { + try? await Task.sleep(nanoseconds: 700 * NSEC_PER_MSEC) + await process() + } + } + } + + func append(_ msg: ArchMsg) async { + accumulator.append(msg) + if accumulator.count >= Const.mamRequestPageSize { + await process() + } + } + + func process() async { + if accumulator.isEmpty { return } + await handleMessages(accumulator) + accumulator.removeAll() + } + + private func handleMessages(_ received: [ArchMsg]) async { + if received.isEmpty { return } + try? await Database.shared.dbQueue.write { db in + for recv in received { + let message = recv.message + let date = recv.timestamp + if let msgId = message.id { + if try Message.fetchOne(db, key: msgId) != nil { + #if DEBUG + print("---") + print("Skipping archived message with id \(msgId) (message exists)") + print("---") + #endif + } else { + #if DEBUG + print("---") + print("Archive message received: \(message)") + print("Date: \(date)") + print("---") + #endif + if var msg = Message.map(message, context: nil) { + msg.date = date + try msg.insert(db) + } + } + } + } + } + } +} diff --git a/ConversationsClassic/AppData/Client/Client+MartinMessages.swift b/ConversationsClassic/AppData/Client/Client+MartinMessages.swift new file mode 100644 index 0000000..8ee2371 --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client+MartinMessages.swift @@ -0,0 +1,38 @@ +import Combine +import Foundation +import GRDB +import Martin + +final class ClientMartinMessagesManager { + private var cancellables: Set = [] + + init(_ xmppConnection: XMPPClient) { + xmppConnection.module(MessageModule.self).messagesPublisher + .sink { [weak self] message in + self?.handleMessage(message) + } + .store(in: &cancellables) + } + + private func handleMessage(_ received: Martin.MessageModule.MessageReceived) { + let message = received.message + let chat = received.chat + #if DEBUG + print("---") + print("Message received: \(received)") + print("Chat: \(chat)") + print("---") + #endif + + // Process image + if let msg = Message.map(message, context: chat.context) { + Task { + do { + try await msg.save() + } catch { + logIt(.error, "Error saving message: \(error)") + } + } + } + } +} diff --git a/ConversationsClassic/AppData/Client/Client+MartinOMEMO.swift b/ConversationsClassic/AppData/Client/Client+MartinOMEMO.swift new file mode 100644 index 0000000..5d3c520 --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client+MartinOMEMO.swift @@ -0,0 +1,375 @@ +import Foundation +import GRDB +import Martin +import MartinOMEMO + +final class ClientMartinOMEMO { + let credentials: Credentials + + init(_ credentials: Credentials) { + self.credentials = credentials + } + + var signal: (SignalStorage, SignalContext) { + let signalStorage = SignalStorage(sessionStore: self, preKeyStore: self, signedPreKeyStore: self, identityKeyStore: self, senderKeyStore: self) + // swiftlint:disable:next force_unwrapping + let signalContext = SignalContext(withStorage: signalStorage)! + signalStorage.setup(withContext: signalContext) + + _ = regenerateKeys(wipe: false, context: signalContext) + + return (signalStorage, signalContext) + } + + private func regenerateKeys(wipe: Bool = false, context: SignalContext) -> Bool { + if wipe { + OMEMOSession.wipe(account: credentials.bareJid) + OMEMOPreKey.wipe(account: credentials.bareJid) + OMEMOSignedPreKey.wipe(account: credentials.bareJid) + OMEMOIdentity.wipe(account: credentials.bareJid) + Settings.getFor(credentials.bareJid)?.wipeOmemoRegId() + } + + let hasKeyPair = keyPair() != nil + if wipe || localRegistrationId() == 0 || !hasKeyPair { + let regId = context.generateRegistrationId() + let address = SignalAddress(name: credentials.bareJid, deviceId: Int32(regId)) + + if var settings = Settings.getFor(credentials.bareJid) { + settings.omemoRegId = Int(regId) + settings.save() + } else { + Settings(bareJid: credentials.bareJid, omemoRegId: Int(regId)).save() + } + + guard let keyPair = SignalIdentityKeyPair.generateKeyPair(context: context), let publicKey = keyPair.publicKey else { + return false + } + let fingerprint = publicKey.map { byte -> String in + String(format: "%02x", byte) + }.joined() + + return save(address: address, fingerprint: fingerprint, own: true, data: keyPair.serialized()) + } + return true + } + + private func save(address: SignalAddress, fingerprint: String, own: Bool, data: Data) -> Bool { + guard !OMEMOIdentity.existsFor(account: credentials.bareJid, name: address.name, fingerprint: fingerprint) else { + return false + } + + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOIdentity( + account: credentials.bareJid, + name: address.name, + deviceId: Int(address.deviceId), + fingerprint: fingerprint, + key: data, + own: own, + status: MartinOMEMO.IdentityStatus.trustedActive.rawValue + ) + .insert(db) + } + return true + } catch { + logIt(.error, "Error storing identity key: \(error.localizedDescription)") + return false + } + } +} + +// MARK: - Session +extension ClientMartinOMEMO: SignalSessionStoreProtocol { + func sessionRecord(forAddress address: MartinOMEMO.SignalAddress) -> Data? { + if let key = OMEMOSession.keyFor(account: credentials.bareJid, name: address.name, deviceId: address.deviceId) { + return Data(base64Encoded: key) + } else { + return nil + } + } + + func allDevices(for name: String, activeAndTrusted: Bool) -> [Int32] { + activeAndTrusted ? + OMEMOSession.trustedDevicesIdsFor(account: credentials.bareJid, name: name) : + OMEMOSession.devicesIdsFor(account: credentials.bareJid, name: name) + } + + func storeSessionRecord(_ data: Data, forAddress: MartinOMEMO.SignalAddress) -> Bool { + do { + try Database.shared.dbQueue.write { db in + try OMEMOSession( + account: credentials.bareJid, + name: forAddress.name, + deviceId: Int(forAddress.deviceId), + key: data.base64EncodedString() + ) + .insert(db) + } + return true + } catch { + logIt(.error, "Error storing session info: \(error.localizedDescription)") + return false + } + } + + func containsSessionRecord(forAddress: MartinOMEMO.SignalAddress) -> Bool { + OMEMOSession.keyFor(account: credentials.bareJid, name: forAddress.name, deviceId: forAddress.deviceId) != nil + } + + func deleteSessionRecord(forAddress: MartinOMEMO.SignalAddress) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSession + .filter(Column("account") == credentials.bareJid) + .filter(Column("name") == forAddress.name) + .filter(Column("deviceId") == forAddress.deviceId) + .deleteAll(db) + } + return true + } catch { + logIt(.error, "Error deleting session: \(error.localizedDescription)") + return false + } + } + + func deleteAllSessions(for name: String) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSession + .filter(Column("account") == credentials.bareJid) + .filter(Column("name") == name) + .deleteAll(db) + } + return true + } catch { + logIt(.error, "Error deleting all sessions: \(error.localizedDescription)") + return false + } + } + + func sessionsWipe() { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSession + .filter(Column("account") == credentials.bareJid) + .deleteAll(db) + } + } catch { + logIt(.error, "Error wiping sessions: \(error.localizedDescription)") + } + } +} + +// MARK: - Identity +extension ClientMartinOMEMO: SignalIdentityKeyStoreProtocol { + func keyPair() -> (any MartinOMEMO.SignalIdentityKeyPairProtocol)? { + let deviceId = localRegistrationId() + guard deviceId != 0 else { + return nil + } + + do { + let record = try Database.shared.dbQueue.read { db in + try OMEMOIdentity + .filter(Column("account") == credentials.bareJid) + .filter(Column("name") == credentials.bareJid) + .filter(Column("deviceId") == deviceId) + .fetchOne(db) + } + guard let key = record?.key else { + return nil + } + return SignalIdentityKeyPair(fromKeyPairData: key) + } catch { + return nil + } + } + + func localRegistrationId() -> UInt32 { + if let settings = Settings.getFor(credentials.bareJid) { + return UInt32(settings.omemoRegId) + } else { + return 0 + } + } + + func save(identity: MartinOMEMO.SignalAddress, key: (any MartinOMEMO.SignalIdentityKeyProtocol)?) -> Bool { + guard let key = key as SignalIdentityKeyProtocol?, let publicKey = key.publicKey else { + return false + } + let fingerprint = publicKey.map { byte -> String in + String(format: "%02x", byte) + }.joined() + + defer { + _ = self.setStatus(.verifiedActive, forIdentity: identity) + } + + return save(address: identity, fingerprint: fingerprint, own: true, data: key.serialized()) + } + + func save(identity: MartinOMEMO.SignalAddress, publicKeyData: Data?) -> Bool { + guard let publicKeyData = publicKeyData else { + return false + } + let fingerprint = publicKeyData.map { byte -> String in + String(format: "%02x", byte) + }.joined() + + return save(address: identity, fingerprint: fingerprint, own: false, data: publicKeyData) + } + + func isTrusted(identity _: MartinOMEMO.SignalAddress, key _: (any MartinOMEMO.SignalIdentityKeyProtocol)?) -> Bool { + true + } + + func isTrusted(identity _: MartinOMEMO.SignalAddress, publicKeyData _: Data?) -> Bool { + true + } + + func setStatus(_ status: MartinOMEMO.IdentityStatus, forIdentity: MartinOMEMO.SignalAddress) -> Bool { + if let identity = OMEMOIdentity.getFor(account: credentials.bareJid, name: forIdentity.name, deviceId: forIdentity.deviceId) { + return identity.updateStatus(status.rawValue) + } else { + return false + } + } + + func setStatus(active: Bool, forIdentity: MartinOMEMO.SignalAddress) -> Bool { + if let identity = OMEMOIdentity.getFor(account: credentials.bareJid, name: forIdentity.name, deviceId: forIdentity.deviceId) { + let status = IdentityStatus(rawValue: identity.status) ?? .undecidedActive + return identity.updateStatus(active ? status.toActive().rawValue : status.toInactive().rawValue) + } else { + return false + } + } + + func identities(forName name: String) -> [MartinOMEMO.Identity] { + OMEMOIdentity.getAllFor(account: credentials.bareJid, name: name) + .compactMap { identity in + guard let status = IdentityStatus(rawValue: identity.status) else { + return nil + } + return MartinOMEMO.Identity( + address: MartinOMEMO.SignalAddress(name: identity.name, deviceId: Int32(identity.deviceId)), + status: status, + fingerprint: identity.fingerprint, + key: identity.key, + own: identity.own + ) + } + } + + func identityFingerprint(forAddress address: MartinOMEMO.SignalAddress) -> String? { + OMEMOIdentity.getFor(account: credentials.bareJid, name: address.name, deviceId: address.deviceId)?.fingerprint + } +} + +// MARK: - PreKey +extension ClientMartinOMEMO: SignalPreKeyStoreProtocol { + func currentPreKeyId() -> UInt32 { + let id = OMEMOPreKey.currentIdFor(account: credentials.bareJid) + return UInt32(id) + } + + func loadPreKey(withId: UInt32) -> Data? { + OMEMOPreKey.keyFor(account: credentials.bareJid, id: withId) + } + + func storePreKey(_ data: Data, withId: UInt32) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOPreKey( + account: credentials.bareJid, + id: Int(withId), + key: data, + markForDeletion: false + ) + .insert(db) + } + return true + } catch { + logIt(.error, "Error pre key store: \(error.localizedDescription)") + return false + } + } + + func containsPreKey(withId: UInt32) -> Bool { + OMEMOPreKey.contains(account: credentials.bareJid, id: withId) + } + + func deletePreKey(withId: UInt32) -> Bool { + OMEMOPreKey.markForDeletion(account: credentials.bareJid, id: withId) + } + + func flushDeletedPreKeys() -> Bool { + OMEMOPreKey.deleteMarked(account: credentials.bareJid) + } + + func preKeysWipe() { + OMEMOPreKey.wipe(account: credentials.bareJid) + } +} + +// MARK: - SignedPreKey +extension ClientMartinOMEMO: SignalSignedPreKeyStoreProtocol { + func countSignedPreKeys() -> Int { + OMEMOSignedPreKey.countsFor(account: credentials.bareJid) + } + + func loadSignedPreKey(withId: UInt32) -> Data? { + OMEMOSignedPreKey.keyFor(account: credentials.bareJid, id: withId) + } + + func storeSignedPreKey(_ data: Data, withId: UInt32) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSignedPreKey( + account: credentials.bareJid, + id: Int(withId), + key: data + ).insert(db) + } + return true + } catch { + logIt(.error, "Error storing signed pre key: \(error.localizedDescription)") + return false + } + } + + func containsSignedPreKey(withId: UInt32) -> Bool { + OMEMOSignedPreKey.keyFor(account: credentials.bareJid, id: withId) != nil + } + + func deleteSignedPreKey(withId: UInt32) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSignedPreKey + .filter(Column("account") == credentials.bareJid) + .filter(Column("id") == withId) + .deleteAll(db) + } + return true + } catch { + logIt(.error, "Error deleting signed pre key: \(error.localizedDescription)") + return false + } + } + + func wipeSignedPreKeys() { + OMEMOSignedPreKey.wipe(account: credentials.bareJid) + } +} + +// MARK: - SenderKey +extension ClientMartinOMEMO: SignalSenderKeyStoreProtocol { + func storeSenderKey(_: Data, address _: MartinOMEMO.SignalAddress?, groupId _: String?) -> Bool { + false + } + + func loadSenderKey(forAddress _: MartinOMEMO.SignalAddress?, groupId _: String?) -> Data? { + nil + } +} diff --git a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinRosterManager.swift b/ConversationsClassic/AppData/Client/Client+MartinRosters.swift similarity index 89% rename from ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinRosterManager.swift rename to ConversationsClassic/AppData/Client/Client+MartinRosters.swift index 194dc29..4ade0af 100644 --- a/ConversationsClassic/AppCore/Database/Database+Martin/Database+MartinRosterManager.swift +++ b/ConversationsClassic/AppData/Client/Client+MartinRosters.swift @@ -2,11 +2,10 @@ import Foundation import GRDB import Martin -extension Database: Martin.RosterManager { +final class ClientMartinRosterManager: Martin.RosterManager { func clear(for context: Martin.Context) { - print("Clearing roster for context: \(context)") do { - try _db.write { db in + try Database.shared.dbQueue.write { db in try Roster .filter(Column("bareJid") == context.userBareJid.stringValue) .deleteAll(db) @@ -22,7 +21,7 @@ extension Database: Martin.RosterManager { func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] { do { - let rosters: [Roster] = try _db.read { db in + let rosters: [Roster] = try Database.shared.dbQueue.read { db in try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db) } return rosters.map { roster in @@ -43,7 +42,7 @@ extension Database: Martin.RosterManager { func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? { do { - let roster: Roster? = try _db.read { db in + let roster: Roster? = try Database.shared.dbQueue.read { db in try Roster .filter(Column("bareJid") == context.userBareJid.stringValue) .filter(Column("contactBareJid") == jid.stringValue) @@ -80,7 +79,7 @@ extension Database: Martin.RosterManager { annotations: annotations ) ) - try _db.write { db in + try Database.shared.dbQueue.write { db in try roster.save(db) } return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations) @@ -92,14 +91,14 @@ extension Database: Martin.RosterManager { func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? { do { - let roster: Roster? = try _db.read { db in + let roster: Roster? = try Database.shared.dbQueue.read { db in try Roster .filter(Column("bareJid") == context.userBareJid.stringValue) .filter(Column("contactBareJid") == jid.stringValue) .fetchOne(db) } if let roster { - _ = try _db.write { db in + _ = try Database.shared.dbQueue.write { db in try roster.delete(db) } return RosterItemBase( @@ -121,7 +120,7 @@ extension Database: Martin.RosterManager { func version(for context: Martin.Context) -> String? { do { - let version: RosterVersion? = try _db.read { db in + let version: RosterVersion? = try Database.shared.dbQueue.read { db in try RosterVersion .filter(Column("bareJid") == context.userBareJid.stringValue) .fetchOne(db) @@ -136,7 +135,7 @@ extension Database: Martin.RosterManager { func set(version: String?, for context: Martin.Context) { guard let version else { return } do { - try _db.write { db in + try Database.shared.dbQueue.write { db in let rosterVersion = RosterVersion( bareJid: context.userBareJid.stringValue, version: version diff --git a/ConversationsClassic/AppData/Client/Client.swift b/ConversationsClassic/AppData/Client/Client.swift new file mode 100644 index 0000000..418dce6 --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client.swift @@ -0,0 +1,235 @@ +import Combine +import Foundation +import GRDB +import Martin +import MartinOMEMO + +enum ClientState: Equatable { + enum ClientConnectionState { + case connected + case disconnected + } + + case disabled + case enabled(ClientConnectionState) +} + +final class Client: ObservableObject { + @Published private(set) var state: ClientState = .enabled(.disconnected) + @Published private(set) var credentials: Credentials + @Published private(set) var rosters: [Roster] = [] + + private var connection: XMPPClient + private var connectionCancellable: AnyCancellable? + private var rostersCancellable: AnyCancellable? + + private var rosterManager = ClientMartinRosterManager() + private var chatsManager = ClientMartinChatsManager() + private var discoManager: ClientMartinDiscoManager + private var messageManager: ClientMartinMessagesManager + private var carbonsManager: ClientMartinCarbonsManager + private var mamManager: ClientMartinMAM + + init(credentials: Credentials) { + self.credentials = credentials + state = credentials.isActive ? .enabled(.disconnected) : .disabled + connection = Self.prepareConnection(credentials, rosterManager, chatsManager) + discoManager = ClientMartinDiscoManager(connection) + messageManager = ClientMartinMessagesManager(connection) + carbonsManager = ClientMartinCarbonsManager(connection) + mamManager = ClientMartinMAM(connection) + connectionCancellable = connection.$state + .sink { [weak self] state in + guard let self = self else { return } + guard self.credentials.isActive else { + self.state = .disabled + return + } + rostersCancellable = ValueObservation + .tracking { db in + try Roster + .filter(Column("bareJid") == self.credentials.bareJid) + .filter(Column("locallyDeleted") == false) + .fetchAll(db) + } + .publisher(in: Database.shared.dbQueue) + .catch { _ in Just([]) } + .sink { rosters in + self.rosters = rosters + } + switch state { + case .connected: + self.state = .enabled(.connected) + + default: + self.state = .enabled(.disconnected) + } + } + } +} + +extension Client { + func addRoster(_ jid: String, name: String?, groups: [String]) async throws { + _ = try await connection.module(.roster).addItem( + jid: JID(jid), + name: name, + groups: groups + ) + } + + func deleteRoster(_ roster: Roster) async throws { + _ = try await connection.module(.roster).removeItem(jid: JID(roster.contactBareJid)) + } + + func connect() async { + guard credentials.isActive, state == .enabled(.disconnected) else { + return + } + try? await connection.loginAndWait() + } + + func disconnect() { + _ = connection.disconnect() + } +} + +extension Client { + func sendMessage(_ message: Message) async throws { + guard let to = message.to else { + return + } + guard let chat = connection.module(MessageModule.self).chatManager.createChat(for: connection.context, with: BareJID(to)) else { + return + } + + var msg = chat.createMessage(text: message.body ?? "??", id: message.id) + msg.oob = message.oobUrl + msg = try await encryptMessage(msg) + try await chat.send(message: msg) + } + + func uploadFile(_ localURL: URL) async throws -> String { + guard let data = try? Data(contentsOf: localURL) else { + throw AppError.noData + } + let httpModule = connection.module(HttpFileUploadModule.self) + + let components = try await httpModule.findHttpUploadComponents() + guard let component = components.first(where: { $0.maxSize > data.count }) else { + throw AppError.fileTooBig + } + + let slot = try await httpModule.requestUploadSlot( + componentJid: component.jid, + filename: localURL.lastPathComponent, + size: data.count, + contentType: localURL.mimeType + ) + var request = URLRequest(url: slot.putUri) + for (key, value) in slot.putHeaders { + request.addValue(value, forHTTPHeaderField: key) + } + request.httpMethod = "PUT" + request.httpBody = data + request.addValue(String(data.count), forHTTPHeaderField: "Content-Length") + request.addValue(localURL.mimeType, forHTTPHeaderField: "Content-Type") + let (_, response) = try await URLSession.shared.data(for: request) + switch response { + case let httpResponse as HTTPURLResponse where httpResponse.statusCode == 201: + return slot.getUri.absoluteString + + default: + throw URLError(.badServerResponse) + } + } + + func fetchArchiveMessages(for roster: Roster, query: RSM.Query) async throws -> Martin.MessageArchiveManagementModule.QueryResult { + if !discoManager.features.map({ $0.xep }).contains("XEP-0313") { + throw AppError.featureNotSupported + } + let module = connection.module(MessageArchiveManagementModule.self) + return try await module.queryItems(componentJid: JID(roster.bareJid), with: JID(roster.contactBareJid), queryId: UUID().uuidString, rsm: query) + } +} + +private extension Client { + func encryptMessage(_ message: Martin.Message) async throws -> Martin.Message { + try await withCheckedThrowingContinuation { continuation in + connection.module(.omemo).encode(message: message, completionHandler: { result in + switch result { + case .successMessage(let encodedMessage, _): + // guard connection.isConnected else { + // continuation.resume(returning: message) + // return + // } + continuation.resume(returning: encodedMessage) + + case .failure(let error): + var errorMessage = NSLocalizedString("It was not possible to send encrypted message due to encryption error", comment: "message encryption failure") + switch error { + case .noSession: + errorMessage = NSLocalizedString("There is no trusted device to send message to", comment: "message encryption failure") + default: + break + } + continuation.resume(throwing: XMPPError.unexpected_request(errorMessage)) + } + }) + } + } +} + +extension Client { + static func tryLogin(with credentials: Credentials) async throws -> Client { + let client = Client(credentials: credentials) + try await client.connection.loginAndWait() + return client + } +} + +private extension Client { + static func prepareConnection(_ credentials: Credentials, _ roster: RosterManager, _ chat: ChatManager) -> XMPPClient { + let client = XMPPClient() + + // register modules + // core modules RFC 6120 + client.modulesManager.register(StreamFeaturesModule()) + client.modulesManager.register(SaslModule()) + client.modulesManager.register(AuthModule()) + client.modulesManager.register(SessionEstablishmentModule()) + client.modulesManager.register(ResourceBinderModule()) + client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName))) + + // messaging modules RFC 6121 + client.modulesManager.register(RosterModule(rosterManager: roster)) + client.modulesManager.register(PresenceModule()) + + client.modulesManager.register(PubSubModule()) + client.modulesManager.register(MessageModule(chatManager: chat)) + client.modulesManager.register(MessageArchiveManagementModule()) + + client.modulesManager.register(MessageCarbonsModule()) + + // file transfer modules + client.modulesManager.register(HttpFileUploadModule()) + + // extensions + client.modulesManager.register(SoftwareVersionModule()) + client.modulesManager.register(PingModule()) + client.connectionConfiguration.userJid = .init(credentials.bareJid) + client.connectionConfiguration.credentials = .password(password: credentials.pass) + + // OMEMO + let omemoManager = ClientMartinOMEMO(credentials) + let (signalStorage, signalContext) = omemoManager.signal + client.modulesManager.register(OMEMOModule(aesGCMEngine: AESGSMEngine.shared, signalContext: signalContext, signalStorage: signalStorage)) + + // group chats + // client.modulesManager.register(MucModule(roomManager: manager)) + + // channels + // client.modulesManager.register(MixModule(channelManager: manager)) + + return client + } +} diff --git a/ConversationsClassic/AppData/Model/Chat.swift b/ConversationsClassic/AppData/Model/Chat.swift new file mode 100644 index 0000000..0f49c7b --- /dev/null +++ b/ConversationsClassic/AppData/Model/Chat.swift @@ -0,0 +1,34 @@ +import Foundation +import GRDB + +enum ConversationType: Int, Codable, DatabaseValueConvertible { + case chat = 0 + case room = 1 + case channel = 2 +} + +struct Chat: DBStorable { + static let databaseTableName = "chats" + + var id: String + var account: String + var participant: String + var type: ConversationType +} + +extension Chat: Equatable {} + +extension Chat { + func fetchRoster() async throws -> Roster { + try await Database.shared.dbQueue.read { db in + guard + let roster = try Roster + .filter(Column("bareJid") == account && Column("contactBareJid") == participant) + .fetchOne(db) + else { + throw AppError.rosterNotFound + } + return roster + } + } +} diff --git a/ConversationsClassic/AppData/Model/Credentials.swift b/ConversationsClassic/AppData/Model/Credentials.swift new file mode 100644 index 0000000..32cca11 --- /dev/null +++ b/ConversationsClassic/AppData/Model/Credentials.swift @@ -0,0 +1,32 @@ +import Combine +import Foundation +import GRDB +import SwiftUI + +struct Credentials: DBStorable, Hashable { + static let databaseTableName = "credentials" + + var id: String { bareJid } + var bareJid: String + var pass: String + var isActive: Bool + + func save() async throws { + let db = Database.shared.dbQueue + try await db.write { db in + try self.save(db) + } + } + + func delete() async throws { + let db = Database.shared.dbQueue + _ = try await db.write { db in + try self.delete(db) + } + } +} + +extension Credentials: UniversalInputSelectionElement { + var text: String? { bareJid } + var icon: Image? { nil } +} diff --git a/ConversationsClassic/AppData/Model/GalleryItem.swift b/ConversationsClassic/AppData/Model/GalleryItem.swift new file mode 100644 index 0000000..8a57186 --- /dev/null +++ b/ConversationsClassic/AppData/Model/GalleryItem.swift @@ -0,0 +1,53 @@ +import Photos +import SwiftUI + +enum GalleryMediaType { + case video + case photo +} + +struct GalleryItem: Identifiable { + let id: String + let type: GalleryMediaType + var thumbnail: Image? + var duration: String? +} + +extension GalleryItem { + static func fetchAll() async -> [GalleryItem] { + await Task { + let fetchOptions = PHFetchOptions() + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let assets = PHAsset.fetchAssets(with: fetchOptions) + var tmpGalleryItems: [GalleryItem] = [] + assets.enumerateObjects { asset, _, _ in + if asset.mediaType == .image { + let item = GalleryItem(id: asset.localIdentifier, type: .photo, thumbnail: nil, duration: nil) + tmpGalleryItems.append(item) + } + if asset.mediaType == .video { + let item = GalleryItem(id: asset.localIdentifier, type: .video, thumbnail: nil, duration: asset.duration.minAndSec) + tmpGalleryItems.append(item) + } + } + return tmpGalleryItems + }.value + } + + mutating func fetchThumbnail() async throws { + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { return } + let size = CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize) + + switch type { + case .photo: + let originalImage = try await PHImageManager.default().getPhoto(for: asset) + let cropped = try await originalImage.scaleAndCropImage(size) + thumbnail = Image(uiImage: cropped) + + case .video: + let avAsset = try await PHImageManager.default().getVideo(for: asset) + let cropped = try await avAsset.generateVideoThumbnail(size) + thumbnail = Image(uiImage: cropped) + } + } +} diff --git a/ConversationsClassic/AppData/Model/Message+OMEMO.swift b/ConversationsClassic/AppData/Model/Message+OMEMO.swift new file mode 100644 index 0000000..538d3e7 --- /dev/null +++ b/ConversationsClassic/AppData/Model/Message+OMEMO.swift @@ -0,0 +1,76 @@ +import Foundation +import GRDB +import Martin + +extension Message { + static func map(_ martinMessage: Martin.Message, context: Martin.Context?) -> Message? { + // Check that the message type is supported + var martinMessage = martinMessage + let chatTypes: [StanzaType] = [.chat, .groupchat] + guard let mType = martinMessage.type, chatTypes.contains(mType) else { + #if DEBUG + print("Unsupported martinMessage type: \(martinMessage.type?.rawValue ?? "nil")") + #endif + return nil + } + + // Type + let type = MessageType(rawValue: martinMessage.type?.rawValue ?? "") ?? .chat + + // Content type + var contentType: MessageContentType = .text + if let oob = martinMessage.oob { + contentType = .attachment(.init( + type: oob.attachmentType, + localName: nil, + thumbnailName: nil, + remotePath: oob + )) + } else if martinMessage.hints.contains(.noStore) { + contentType = .typing + // skip for now + return nil + } + + // Try to recognize if message is omemo-encoded and decode it + if let omemo = context?.module(.omemo) { + let decodingResult = omemo.decode(message: martinMessage) + switch decodingResult { + case .successMessage(let decodedMessage, _): + martinMessage = decodedMessage + // print(decodedMessage, fingerprint) + + case .successTransportKey: + break + + case .failure(let error): + logIt(.error, "Error decoding omemo message: \(error)") + } + } + + // skip for non-visible messages + if martinMessage.body == nil, martinMessage.oob == nil, martinMessage.type == .chat { + return nil + } + + // From/To + let from = martinMessage.from?.bareJid.stringValue ?? "" + let to = martinMessage.to?.bareJid.stringValue + + // Msg + let msg = Message( + id: martinMessage.id ?? UUID().uuidString, + type: type, + date: Date(), + contentType: contentType, + status: .sent, + from: from, + to: to, + body: martinMessage.body, + subject: martinMessage.subject, + thread: martinMessage.thread, + oobUrl: martinMessage.oob + ) + return msg + } +} diff --git a/ConversationsClassic/AppData/Model/Message.swift b/ConversationsClassic/AppData/Model/Message.swift new file mode 100644 index 0000000..4b51cab --- /dev/null +++ b/ConversationsClassic/AppData/Model/Message.swift @@ -0,0 +1,103 @@ +import Foundation +import GRDB +import Martin + +enum MessageType: String, Codable, DatabaseValueConvertible { + case chat + case groupchat + case error +} + +enum AttachmentType: Int, Codable, DatabaseValueConvertible { + case image + case video + case audio + case file +} + +struct Attachment: Codable & Equatable, DatabaseValueConvertible { + let type: AttachmentType + var localName: String? + var thumbnailName: String? + var remotePath: String? + + var localPath: URL? { + guard let attachmentLocalName = localName else { return nil } + return FolderWrapper.shared.fileFolder.appendingPathComponent(attachmentLocalName) + } + + var thumbnailPath: URL? { + guard let attachmentThumbnailName = thumbnailName else { return nil } + return FolderWrapper.shared.fileFolder.appendingPathComponent(attachmentThumbnailName) + } +} + +enum MessageContentType: Codable & Equatable, DatabaseValueConvertible { + case text + case typing + case invite + case attachment(Attachment) + + var isAttachment: Bool { + if case .attachment = self { + return true + } + return false + } +} + +enum MessageStatus: Int, Codable, DatabaseValueConvertible { + case pending + case sent + case error +} + +struct Message: DBStorable, Equatable { + static let databaseTableName = "messages" + + let id: String + var type: MessageType + var date: Date + var contentType: MessageContentType + var status: MessageStatus + + var from: String + var to: String? + + var body: String? + var subject: String? + var thread: String? + var oobUrl: String? +} + +extension Message { + func save() async throws { + try await Database.shared.dbQueue.write { db in + try self.insert(db) + } + } + + func setStatus(_ status: MessageStatus) async throws { + try await Database.shared.dbQueue.write { db in + var updatedMessage = self + updatedMessage.status = status + try updatedMessage.update(db, columns: ["status"]) + } + } + + static var blank: Message { + Message( + id: UUID().uuidString, + type: .chat, + date: Date(), + contentType: .text, + status: .pending, + from: "", + to: nil, + body: nil, + subject: nil, + thread: nil, + oobUrl: nil + ) + } +} diff --git a/ConversationsClassic/AppData/Model/OMEMO.swift b/ConversationsClassic/AppData/Model/OMEMO.swift new file mode 100644 index 0000000..5a91842 --- /dev/null +++ b/ConversationsClassic/AppData/Model/OMEMO.swift @@ -0,0 +1,314 @@ +import Foundation +import GRDB +import Martin + +// MARK: - Session +struct OMEMOSession: DBStorable { + static let databaseTableName = "omemo_sessions" + + let account: String + let name: String + let deviceId: Int + let key: String + + var id: String { + "\(account)_\(name)_\(deviceId)" + } +} + +extension OMEMOSession { + static func keyFor(account: String, name: String, deviceId: Int32) -> String? { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOSession + .filter(Column("account") == account) + .filter(Column("name") == name) + .filter(Column("deviceId") == deviceId) + .fetchOne(db) + }?.key + } catch { + return nil + } + } + + static func devicesIdsFor(account: String, name: String) -> [Int32] { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOSession + .filter(Column("account") == account) + .filter(Column("name") == name) + .fetchAll(db) + .map(\.deviceId) + }.map { Int32($0) } + } catch { + return [] + } + } + + static func trustedDevicesIdsFor(account: String, name: String) -> [Int32] { + do { + let sql = + """ + SELECT s.device_id + FROM omemo_sessions s + LEFT JOIN omemo_identities i + ON s.account = i.account + AND s.name = i.name + AND s.device_id = i.device_id + WHERE s.account = :account + AND s.name = :name + AND ((i.status >= 0 AND i.status % 2 = 0) OR i.status IS NULL) + """ + + let arguments: StatementArguments = ["account": account, "name": name] + + return try Database.shared.dbQueue.read { db in + try Int32.fetchAll(db, sql: sql, arguments: arguments) + } + } catch { + return [] + } + } + + static func wipe(account: String) { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSession + .filter(Column("account") == account) + .deleteAll(db) + } + } catch { + logIt(.error, "Failed to wipe OMEMO session: \(error)") + } + } +} + +// MARK: - Identity +struct OMEMOIdentity: DBStorable { + static let databaseTableName = "omemo_identities" + + let account: String + let name: String + let deviceId: Int + let fingerprint: String + let key: Data + let own: Bool + let status: Int + + var id: String { + "\(account)_\(name)_\(deviceId)" + } +} + +extension OMEMOIdentity { + static func wipe(account: String) { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOIdentity + .filter(Column("account") == account) + .deleteAll(db) + } + } catch { + logIt(.error, "Failed to wipe OMEMO identity: \(error)") + } + } + + static func getFor(account: String, name: String, deviceId: Int32) -> OMEMOIdentity? { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOIdentity + .filter(Column("account") == account) + .filter(Column("name") == name) + .filter(Column("deviceId") == deviceId) + .fetchOne(db) + } + } catch { + return nil + } + } + + static func existsFor(account: String, name: String, fingerprint: String) -> Bool { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOIdentity + .filter(Column("account") == account) + .filter(Column("name") == name) + .filter(Column("fingerprint") == fingerprint) + .fetchOne(db) != nil + } + } catch { + return false + } + } + + func updateStatus(_ status: Int) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOIdentity + .filter(Column("account") == account) + .filter(Column("name") == name) + .filter(Column("deviceId") == deviceId) + .updateAll(db, Column("status").set(to: status)) + } + return true + } catch { + logIt(.error, "Failed to update OMEMO identity status: \(error)") + return false + } + } + + static func getAllFor(account: String, name: String) -> [OMEMOIdentity] { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOIdentity + .filter(Column("account") == account) + .filter(Column("name") == name) + .fetchAll(db) + } + } catch { + return [] + } + } +} + +// MARK: - PreKey +struct OMEMOPreKey: DBStorable { + static let databaseTableName = "omemo_pre_keys" + + let account: String + let id: Int + let key: Data + let markForDeletion: Bool +} + +extension OMEMOPreKey { + static func wipe(account: String) { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOPreKey + .filter(Column("account") == account) + .deleteAll(db) + } + } catch { + logIt(.error, "Failed to wipe OMEMO pre key: \(error)") + } + } + + static func currentIdFor(account: String) -> Int { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOPreKey + .filter(Column("account") == account) + .order(Column("id").desc) + .fetchOne(db) + .map(\.id) + } ?? 0 + } catch { + return 0 + } + } + + static func keyFor(account: String, id: UInt32) -> Data? { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOPreKey + .filter(Column("account") == account) + .filter(Column("id") == id) + .fetchOne(db) + }?.key + } catch { + return nil + } + } + + static func contains(account: String, id: UInt32) -> Bool { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOPreKey + .filter(Column("account") == account) + .filter(Column("id") == id) + .fetchOne(db) != nil + } + } catch { + return false + } + } + + static func markForDeletion(account: String, id: UInt32) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOPreKey + .filter(Column("account") == account) + .filter(Column("id") == id) + .updateAll(db, Column("markForDeletion").set(to: true)) + } + return true + } catch { + logIt(.error, "Failed to mark OMEMO pre key for deletion: \(error)") + return false + } + } + + static func deleteMarked(account: String) -> Bool { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOPreKey + .filter(Column("account") == account) + .filter(Column("markForDeletion") == true) + .deleteAll(db) + } + return true + } catch { + logIt(.error, "Failed to delete marked OMEMO pre keys: \(error)") + return false + } + } +} + +// MARK: - SignedPreKey +struct OMEMOSignedPreKey: DBStorable { + static let databaseTableName = "omemo_signed_pre_keys" + + let account: String + let id: Int + let key: Data +} + +extension OMEMOSignedPreKey { + static func wipe(account: String) { + do { + _ = try Database.shared.dbQueue.write { db in + try OMEMOSignedPreKey + .filter(Column("account") == account) + .deleteAll(db) + } + } catch { + logIt(.error, "Failed to wipe OMEMO signed pre key: \(error)") + } + } + + static func countsFor(account: String) -> Int { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOSignedPreKey + .filter(Column("account") == account) + .fetchCount(db) + } + } catch { + return 0 + } + } + + static func keyFor(account: String, id: UInt32) -> Data? { + do { + return try Database.shared.dbQueue.read { db in + try OMEMOSignedPreKey + .filter(Column("account") == account) + .filter(Column("id") == id) + .fetchOne(db) + }?.key + } catch { + return nil + } + } +} diff --git a/ConversationsClassic/AppCore/Models/Roster.swift b/ConversationsClassic/AppData/Model/Roster.swift similarity index 60% rename from ConversationsClassic/AppCore/Models/Roster.swift rename to ConversationsClassic/AppData/Model/Roster.swift index b4653cb..9909704 100644 --- a/ConversationsClassic/AppCore/Models/Roster.swift +++ b/ConversationsClassic/AppData/Model/Roster.swift @@ -60,3 +60,45 @@ extension Roster: Equatable { lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid } } + +extension Roster { + mutating func setLocallyDeleted(_ value: Bool) async throws { + locallyDeleted = value + let copy = self + try? await Database.shared.dbQueue.write { db in + try copy.save(db) + } + } +} + +extension Roster { + static var allDeletedLocally: [Roster] { + get async { + do { + let rosters = try await Database.shared.dbQueue.read { db in + try Roster + .filter(Column("locallyDeleted") == true) + .fetchAll(db) + } + return rosters + } catch { + return [] + } + } + } + + static var allActive: [Roster] { + get async { + do { + let rosters = try await Database.shared.dbQueue.read { db in + try Roster + .filter(Column("locallyDeleted") == false) + .fetchAll(db) + } + return rosters + } catch { + return [] + } + } + } +} diff --git a/ConversationsClassic/AppData/Model/ServerFeature.swift b/ConversationsClassic/AppData/Model/ServerFeature.swift new file mode 100644 index 0000000..bc04363 --- /dev/null +++ b/ConversationsClassic/AppData/Model/ServerFeature.swift @@ -0,0 +1,21 @@ +import Foundation + +struct ServerFeature: Identifiable & Codable { + let xep: String + let name: String + let xmppId: String? + let description: String? + + var id: String { xep } + + static var allFeatures: [ServerFeature] { + guard + let url = Bundle.main.url(forResource: "server_features", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let loaded = try? PropertyListDecoder().decode([ServerFeature].self, from: data) + else { + return [] + } + return loaded + } +} diff --git a/ConversationsClassic/AppData/Model/Settings.swift b/ConversationsClassic/AppData/Model/Settings.swift new file mode 100644 index 0000000..9405a6f --- /dev/null +++ b/ConversationsClassic/AppData/Model/Settings.swift @@ -0,0 +1,49 @@ +import Foundation +import GRDB + +struct Settings: DBStorable { + static let databaseTableName = "settings" + + let bareJid: String + var omemoRegId: Int + + var id: String { + bareJid + } +} + +extension Settings { + static func getFor(_ bareJid: String) -> Settings? { + do { + return try Database.shared.dbQueue.read { db in + let settings = try Settings.filter(Column("bareJid") == bareJid).fetchOne(db) + return settings + } + } catch { + logIt(.error, "Settings not exists for \(bareJid)") + return nil + } + } + + func wipeOmemoRegId() { + do { + _ = try Database.shared.dbQueue.write { db in + try Settings + .filter(Column("bareJid") == bareJid) + .updateAll(db, Column("omemoRegId").set(to: 0)) + } + } catch { + logIt(.error, "Failed to wipe omemoRegId for \(bareJid)") + } + } + + func save() { + do { + try Database.shared.dbQueue.write { db in + try self.insert(db) + } + } catch { + logIt(.error, "Failed to save settings for \(bareJid)") + } + } +} diff --git a/ConversationsClassic/AppData/Services/AESGSMEngine.swift b/ConversationsClassic/AppData/Services/AESGSMEngine.swift new file mode 100644 index 0000000..114514f --- /dev/null +++ b/ConversationsClassic/AppData/Services/AESGSMEngine.swift @@ -0,0 +1,47 @@ +import CryptoKit +import Foundation +import MartinOMEMO + +final class AESGSMEngine: AES_GCM_Engine { + static let shared = AESGSMEngine() + + private init() {} + + func encrypt(iv: Data, key: Data, message: Data, output: UnsafeMutablePointer?, tag: UnsafeMutablePointer?) -> Bool { + do { + let symmetricKey = SymmetricKey(data: key) + let sealedBox = try AES.GCM.seal(message, using: symmetricKey, nonce: AES.GCM.Nonce(data: iv)) + + if let output = output { + output.pointee = sealedBox.ciphertext + } + if let tag = tag { + tag.pointee = sealedBox.tag + } + return true + } catch { + print("Encryption error: \(error)") + return false + } + } + + func decrypt(iv: Data, key: Data, encoded: Data, auth tag: Data?, output: UnsafeMutablePointer?) -> Bool { + do { + let symmetricKey = SymmetricKey(data: key) + guard let tag = tag else { + print("Tag is missing") + return false + } + let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: encoded, tag: tag) + let decryptedData = try AES.GCM.open(sealedBox, using: symmetricKey) + + if let output = output { + output.pointee = decryptedData + } + return true + } catch { + print("Decryption error: \(error)") + return false + } + } +} diff --git a/ConversationsClassic/AppCore/Database/Database+Migrations.swift b/ConversationsClassic/AppData/Services/Database+Migrations.swift similarity index 52% rename from ConversationsClassic/AppCore/Database/Database+Migrations.swift rename to ConversationsClassic/AppData/Services/Database+Migrations.swift index c0336d3..ad23119 100644 --- a/ConversationsClassic/AppCore/Database/Database+Migrations.swift +++ b/ConversationsClassic/AppData/Services/Database+Migrations.swift @@ -12,12 +12,11 @@ extension Database { // 1st migration - basic tables migrator.registerMigration("Add basic tables") { db in - // accounts - try db.create(table: "accounts", options: [.ifNotExists]) { table in + // credentials + try db.create(table: "credentials", options: [.ifNotExists]) { table in table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace) table.column("pass", .text).notNull() table.column("isActive", .boolean).notNull().defaults(to: true) - table.column("isTemp", .boolean).notNull().defaults(to: false) } // rosters @@ -48,40 +47,68 @@ extension Database { try db.create(table: "messages", options: [.ifNotExists]) { table in table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace) table.column("type", .text).notNull() + table.column("date", .datetime).notNull() table.column("contentType", .text).notNull() + table.column("status", .integer).notNull() table.column("from", .text).notNull() table.column("to", .text) table.column("body", .text) table.column("subject", .text) table.column("thread", .text) table.column("oobUrl", .text) - table.column("date", .datetime).notNull() - table.column("pending", .boolean).notNull() - table.column("sentError", .boolean).notNull() - table.column("attachmentType", .integer) - table.column("attachmentLocalName", .text) - table.column("attachmentRemotePath", .text) - table.column("attachmentThumbnailName", .text) - table.column("attachmentDownloadFailed", .boolean).notNull().defaults(to: false) } } - // 2nd migration - channels/rooms - migrator.registerMigration("Add channels/rooms") { db in - // rooms - try db.create(table: "rooms", options: [.ifNotExists]) { table in - table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace) + migrator.registerMigration("Add OMEMO tables") { db in + try db.create(table: "omemo_sessions", options: [.ifNotExists]) { table in table.column("account", .text).notNull() - table.column("nickname", .text).notNull() - table.column("password", .text) + table.column("name", .text).notNull() + table.column("deviceId", .integer).notNull() + table.column("key", .text).notNull() + table.primaryKey(["account", "name", "deviceId"], onConflict: .replace) } - // channels - // try db.create(table: "channels", options: [.ifNotExists]) { table in - // table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace) - // table.column("account", .text).notNull() - // table.column("channel", .text).notNull() + try db.create(table: "omemo_identities", options: [.ifNotExists]) { table in + table.column("account", .text).notNull() + table.column("name", .text).notNull() + table.column("deviceId", .integer).notNull() + table.column("fingerprint", .text).notNull() + table.column("key", .blob).notNull() + table.column("own", .integer).notNull() + table.column("status", .integer).notNull() + table.primaryKey(["account", "name", "fingerprint"], onConflict: .ignore) + } + + try db.create(table: "omemo_pre_keys", options: [.ifNotExists]) { table in + table.column("account", .text).notNull() + table.column("id", .integer).notNull() + table.column("key", .blob).notNull() + table.column("markForDeletion", .boolean).notNull().defaults(to: false) + table.primaryKey(["account", "id"], onConflict: .replace) + } + + try db.create(table: "omemo_signed_pre_keys", options: [.ifNotExists]) { table in + table.column("account", .text).notNull() + table.column("id", .integer).notNull() + table.column("key", .blob).notNull() + table.primaryKey(["account", "id"], onConflict: .replace) + } + + // try db.alter(table: "chats") { table in + // table.add(column: "encryption", .text) // } + // + // try db.alter(table: "messages") { table in + // table.add(column: "encryption", .integer) + // table.add(column: "fingerprint", .text) + // } + } + + migrator.registerMigration("Add settings table") { db in + try db.create(table: "settings", options: [.ifNotExists]) { table in + table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace) + table.column("omemoRegId", .integer).notNull() + } } // return migrator diff --git a/ConversationsClassic/AppCore/Database/Database.swift b/ConversationsClassic/AppData/Services/Database.swift similarity index 89% rename from ConversationsClassic/AppCore/Database/Database.swift rename to ConversationsClassic/AppData/Services/Database.swift index dc06f02..5b2d77c 100644 --- a/ConversationsClassic/AppCore/Database/Database.swift +++ b/ConversationsClassic/AppData/Services/Database.swift @@ -9,7 +9,7 @@ typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRec // MARK: - Database init final class Database { static let shared = Database() - let _db: DatabaseQueue + let dbQueue: DatabaseQueue private init() { do { @@ -24,7 +24,7 @@ final class Database { // Open or create the database let databaseURL = directoryURL.appendingPathComponent("db.sqlite") - _db = try DatabaseQueue(path: databaseURL.path, configuration: Database.config) + dbQueue = try DatabaseQueue(path: databaseURL.path, configuration: Database.config) // Some debug info #if DEBUG @@ -32,7 +32,7 @@ final class Database { #endif // Apply migrations - try Database.migrator.migrate(_db) + try Database.migrator.migrate(dbQueue) } catch { fatalError("Database initialization failed: \(error)") } diff --git a/ConversationsClassic/AppCore/Middlewares/LoggerMiddleware.swift b/ConversationsClassic/AppData/Services/Logger.swift similarity index 57% rename from ConversationsClassic/AppCore/Middlewares/LoggerMiddleware.swift rename to ConversationsClassic/AppData/Services/Logger.swift index f9fec20..6e69c34 100644 --- a/ConversationsClassic/AppCore/Middlewares/LoggerMiddleware.swift +++ b/ConversationsClassic/AppData/Services/Logger.swift @@ -4,33 +4,6 @@ import SwiftUI let isConsoleLoggingEnabled = false -#if DEBUG - let prefixLength = 400 - func loggerMiddleware() -> Middleware { - { state, action in - let timeStr = dateFormatter.string(from: Date()) - var actionStr = "\(action)" - actionStr = String(actionStr.prefix(prefixLength)) + " ..." - var stateStr = "\(state)" - stateStr = String(stateStr.prefix(prefixLength)) + " ..." - - let str = "\(timeStr) \u{EA86} \(actionStr)\n\(timeStr) \u{F129} \(stateStr)\n" - - print(str) - if isConsoleLoggingEnabled { - NSLog(str) - } - return Empty().eraseToAnyPublisher() - } - } -#else - func loggerMiddleware() -> Middleware { - { _, _ in - Empty().eraseToAnyPublisher() - } - } -#endif - enum LogLevels: String { case info = "\u{F449}" case warning = "\u{F071}" diff --git a/ConversationsClassic/AppData/Services/NetworkMonitor.swift b/ConversationsClassic/AppData/Services/NetworkMonitor.swift new file mode 100644 index 0000000..005bd5e --- /dev/null +++ b/ConversationsClassic/AppData/Services/NetworkMonitor.swift @@ -0,0 +1,37 @@ +import Combine +import Network + +extension NWPathMonitor { + func paths() -> AsyncStream { + AsyncStream { continuation in + pathUpdateHandler = { path in + continuation.yield(path) + } + continuation.onTermination = { [weak self] _ in + self?.cancel() + } + start(queue: DispatchQueue(label: "NSPathMonitor.paths")) + } + } +} + +final actor NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published private(set) var isOnline: Bool = false + + private let monitor = NWPathMonitor() + + init() { + Task(priority: .background) { + await startMonitoring() + } + } + + func startMonitoring() async { + let monitor = NWPathMonitor() + for await path in monitor.paths() { + isOnline = path.status == .satisfied + } + } +} diff --git a/ConversationsClassic/AppData/Store/AttachmentsStore.swift b/ConversationsClassic/AppData/Store/AttachmentsStore.swift new file mode 100644 index 0000000..c9f1f11 --- /dev/null +++ b/ConversationsClassic/AppData/Store/AttachmentsStore.swift @@ -0,0 +1,353 @@ +import Combine +import Foundation +import GRDB +import Photos +import SwiftUI + +@MainActor +final class AttachmentsStore: ObservableObject { + @Published private(set) var cameraAccessGranted = false + @Published private(set) var galleryAccessGranted = false + @Published private(set) var galleryItems: [GalleryItem] = [] + + private let client: Client + private let roster: Roster + + private var messagesCancellable: AnyCancellable? + private var processing: Set = [] + + init(roster: Roster, client: Client) { + self.client = client + self.roster = roster + subscribe() + } +} + +// MARK: - Camera and Gallery access +extension AttachmentsStore { + func checkCameraAuthorization() async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + var isAuthorized = status == .authorized + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: .video) + } + cameraAccessGranted = isAuthorized + } + + func checkGalleryAuthorization() async { + let status = PHPhotoLibrary.authorizationStatus() + var isAuthorized = status == .authorized + if status == .notDetermined { + let req = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + isAuthorized = (req == .authorized) || (req == .limited) + } + galleryAccessGranted = isAuthorized + if isAuthorized { + await fetchGalleryItems() + } + } + + private func fetchGalleryItems() async { + guard galleryAccessGranted else { return } + galleryItems = await GalleryItem.fetchAll() + } +} + +// MARK: - Save outgoing attachments for future uploadings +extension AttachmentsStore { + func sendMedia(_ items: [GalleryItem]) { + Task { + for item in items { + Task { + var message = Message.blank + message.from = roster.bareJid + message.to = roster.contactBareJid + + switch item.type { + case .photo: + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } + guard let photo = try? await PHImageManager.default().getPhoto(for: asset) else { return } + guard let data = photo.jpegData(compressionQuality: 1.0) else { return } + let localName = "\(message.id)_\(UUID().uuidString).jpg" + let localUrl = FolderWrapper.shared.fileFolder.appendingPathComponent(localName) + try? data.write(to: localUrl) + message.contentType = .attachment( + Attachment( + type: .image, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) + ) + try? await message.save() + + case .video: + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } + guard let video = try? await PHImageManager.default().getVideo(for: asset) else { return } + // swiftlint:disable:next force_cast + let assetURL = video as! AVURLAsset + let url = assetURL.url + let localName = "\(message.id)_\(UUID().uuidString).mov" + let localUrl = FolderWrapper.shared.fileFolder.appendingPathComponent(localName) + try? FileManager.default.copyItem(at: url, to: localUrl) + message.contentType = .attachment( + Attachment( + type: .video, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) + ) + try? await message.save() + } + } + } + } + } + + func sendCaptured(_ data: Data, _ type: GalleryMediaType) { + Task { + var message = Message.blank + message.from = roster.bareJid + message.to = roster.contactBareJid + + let localName: String + let msgType: AttachmentType + do { + (localName, msgType) = try await Task { + // local name + let fileId = UUID().uuidString + let localName: String + let msgType: AttachmentType + switch type { + case .photo: + localName = "\(message.id)_\(fileId).jpg" + msgType = .image + + case .video: + localName = "\(message.id)_\(fileId).mov" + msgType = .video + } + + // save + let localUrl = FolderWrapper.shared.fileFolder.appendingPathComponent(localName) + try data.write(to: localUrl) + return (localName, msgType) + }.value + } catch { + logIt(.error, "Can't save file for uploading: \(error)") + return + } + + // save message + message.contentType = .attachment( + Attachment( + type: msgType, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) + ) + do { + try await message.save() + } catch { + logIt(.error, "Can't save message: \(error)") + return + } + } + } + + func sendDocuments(_ data: [Data], _ extensions: [String]) { + Task { + for (index, data) in data.enumerated() { + Task { + let newMessageId = UUID().uuidString + let fileId = UUID().uuidString + let localName = "\(newMessageId)_\(fileId).\(extensions[index])" + let localUrl = FolderWrapper.shared.fileFolder.appendingPathComponent(localName) + do { + try data.write(to: localUrl) + } catch { + print("FileProcessing: Error writing document: \(error)") + return + } + + var message = Message.blank + message.from = roster.bareJid + message.to = roster.contactBareJid + message.contentType = .attachment( + Attachment( + type: localName.attachmentType, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) + ) + do { + try await message.save() + } catch { + print("FileProcessing: Error saving document: \(error)") + } + } + } + } + } +} + +// MARK: - Processing attachments +private extension AttachmentsStore { + func subscribe() { + messagesCancellable = ValueObservation.tracking(Message + .filter( + (Column("to") == roster.bareJid && Column("from") == roster.contactBareJid) || + (Column("from") == roster.bareJid && Column("to") == roster.contactBareJid) + ) + .order(Column("date").desc) + .fetchAll + ) + .publisher(in: Database.shared.dbQueue, scheduling: .immediate) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] messages in + let forProcessing = messages + .filter { $0.status != .error } + .filter { self?.processing.contains($0.id) == false } + .filter { $0.contentType.isAttachment } + for message in forProcessing { + if case .attachment(let attachment) = message.contentType { + let localPath = attachment.localPath + if localPath != nil, attachment.remotePath == nil { + // Uploading + self?.processing.insert(message.id) + Task { + await self?.uploadAttachment(message) + } + } else if localPath == nil, attachment.remotePath != nil { + // Downloading + self?.processing.insert(message.id) + Task { + await self?.downloadAttachment(message) + } + } else if localPath != nil, attachment.remotePath != nil, attachment.thumbnailName == nil, attachment.type == .image { + // Generate thumbnail + self?.processing.insert(message.id) + Task { + await self?.generateThumbnail(message) + } + } + } + } + } + } +} + +// MARK: - Uploadings/Downloadings +extension AttachmentsStore { + private func uploadAttachment(_ message: Message) async { + do { + try await message.setStatus(.pending) + var message = message + guard case .attachment(let attachment) = message.contentType else { + throw AppError.invalidContentType + } + guard let localName = attachment.localPath else { + throw AppError.invalidLocalName + } + let remotePath = try await client.uploadFile(localName) + message.contentType = .attachment( + Attachment( + type: attachment.type, + localName: attachment.localName, + thumbnailName: nil, + remotePath: remotePath + ) + ) + message.body = remotePath + message.oobUrl = remotePath + try await message.save() + try await client.sendMessage(message) + processing.remove(message.id) + try await message.setStatus(.sent) + } catch { + processing.remove(message.id) + try? await message.setStatus(.error) + } + } + + private func downloadAttachment(_ message: Message) async { + guard case .attachment(let attachment) = message.contentType else { + return + } + guard let remotePath = attachment.remotePath, let remoteUrl = URL(string: remotePath) else { + return + } + do { + let localName = "\(message.id)_\(UUID().uuidString).\(remoteUrl.lastPathComponent)" + let localUrl = FolderWrapper.shared.fileFolder.appendingPathComponent(localName) + + // Download the file + let (tempUrl, _) = try await URLSession.shared.download(from: remoteUrl) + try FileManager.default.moveItem(at: tempUrl, to: localUrl) + + var message = message + message.contentType = .attachment( + Attachment( + type: attachment.type, + localName: localName, + thumbnailName: attachment.thumbnailName, + remotePath: remotePath + ) + ) + processing.remove(message.id) + try await message.save() + } catch { + logIt(.error, "Can't download attachment: \(error)") + } + } + + private func generateThumbnail(_ message: Message) async { + guard case .attachment(let attachment) = message.contentType else { + return + } + guard attachment.type == .image else { + return + } + guard let localName = attachment.localName, let localPath = attachment.localPath else { + return + } + let thumbnailFileName = "thumb_\(localName)" + let thumbnailUrl = FolderWrapper.shared.fileFolder.appendingPathComponent(thumbnailFileName) + + // + if !FileManager.default.fileExists(atPath: thumbnailUrl.path) { + guard let image = UIImage(contentsOfFile: localPath.path) else { + return + } + let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) + guard let thumbnail = try? await image.scaleAndCropImage(targetSize) else { + return + } + guard let data = thumbnail.jpegData(compressionQuality: 0.5) else { + return + } + do { + try data.write(to: thumbnailUrl) + } catch { + return + } + } + + // + var message = message + message.contentType = .attachment( + Attachment( + type: attachment.type, + localName: attachment.localName, + thumbnailName: thumbnailFileName, + remotePath: attachment.remotePath + ) + ) + processing.remove(message.id) + try? await message.save() + } +} diff --git a/ConversationsClassic/AppData/Store/ClientsStore.swift b/ConversationsClassic/AppData/Store/ClientsStore.swift new file mode 100644 index 0000000..58dc65c --- /dev/null +++ b/ConversationsClassic/AppData/Store/ClientsStore.swift @@ -0,0 +1,188 @@ +import Combine +import Foundation +import GRDB + +@MainActor +final class ClientsStore: ObservableObject { + static let shared = ClientsStore() + + @Published private(set) var ready = false + @Published private(set) var clients: [Client] = [] + @Published private(set) var actualRosters: [Roster] = [] + @Published private(set) var actualChats: [Chat] = [] + + private var credentialsCancellable: AnyCancellable? + private var rostersCancellable: AnyCancellable? + private var chatsCancellable: AnyCancellable? + + init() { + credentialsCancellable = ValueObservation + .tracking { db in + try Credentials.fetchAll(db) + } + .publisher(in: Database.shared.dbQueue) + .catch { _ in Just([]) } + .sink { [weak self] creds in + self?.processCredentials(creds) + } + } + + private func processCredentials(_ credentials: [Credentials]) { + let existsJids = Set(clients.map { $0.credentials.bareJid }) + let credentialsJids = Set(credentials.map { $0.bareJid }) + + let forAdd = credentials.filter { !existsJids.contains($0.bareJid) } + let newClients = forAdd.map { Client(credentials: $0) } + + let forRemove = clients.filter { !credentialsJids.contains($0.credentials.bareJid) } + forRemove.forEach { $0.disconnect() } + + var updatedClients = clients.filter { credentialsJids.contains($0.credentials.bareJid) } + updatedClients.append(contentsOf: newClients) + clients = updatedClients + + if !ready { + ready = true + } + + resubscribeRosters() + resubscribeChats() + reconnectAll() + } + + private func client(for credentials: Credentials) -> Client? { + clients.first { $0.credentials == credentials } + } +} + +// MARK: - Login/Connections +extension ClientsStore { + func tryLogin(_ jidStr: String, _ pass: String) async throws { + // login with fake timeout + async let sleep: Void? = try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) + async let request = try await Client.tryLogin(with: .init(bareJid: jidStr, pass: pass, isActive: true)) + let client = try await(request, sleep).0 + + clients.append(client) + try? await client.credentials.save() + } + + private func reconnectAll() { + Task { + await withTaskGroup(of: Void.self) { taskGroup in + for client in clients { + taskGroup.addTask { + await client.connect() + } + } + } + } + } +} + +// MARK: - Manage Rosters +extension ClientsStore { + func addRoster(_ credentials: Credentials, contactJID: String, name: String?, groups: [String]) async throws { + // check that roster exist in db as locally deleted and undelete it + let deletedLocally = await Roster.allDeletedLocally + if var roster = deletedLocally.first(where: { $0.contactBareJid == contactJID }) { + try await roster.setLocallyDeleted(false) + return + } + + // add new roster + guard let client = client(for: credentials) else { + throw AppError.clientNotFound + } + try await client.addRoster(contactJID, name: name, groups: groups) + } + + func deleteRoster(_ roster: Roster) async throws { + guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { + throw AppError.clientNotFound + } + try await client.deleteRoster(roster) + } +} + +extension ClientsStore { + func addRosterForNewChatIfNeeded(_ chat: Chat) async throws { + let exists = try? await chat.fetchRoster() + if exists == nil { + guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else { + throw AppError.clientNotFound + } + try await addRoster(client.credentials, contactJID: chat.participant, name: nil, groups: []) + } + } +} + +// MARK: - Produce stores for conversation +extension ClientsStore { + func conversationStores(for roster: Roster) async throws -> (MessagesStore, AttachmentsStore) { + while !ready { + await Task.yield() + } + + guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { + throw AppError.clientNotFound + } + + let conversationStore = MessagesStore(roster: roster, client: client) + let attachmentsStore = AttachmentsStore(roster: roster, client: client) + return (conversationStore, attachmentsStore) + } + + func conversationStores(for chat: Chat) async throws -> (MessagesStore, AttachmentsStore) { + while !ready { + await Task.yield() + } + + guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else { + throw AppError.clientNotFound + } + + let roster = try await chat.fetchRoster() + let conversationStore = MessagesStore(roster: roster, client: client) + let attachmentsStore = AttachmentsStore(roster: roster, client: client) + return (conversationStore, attachmentsStore) + } +} + +// MARK: - Subscriptions +private extension ClientsStore { + private func resubscribeRosters() { + let clientsJids = clients + .filter { $0.state != .disabled } + .map { $0.credentials.bareJid } + + rostersCancellable = ValueObservation.tracking { db in + try Roster + .filter(clientsJids.contains(Column("bareJid"))) + .filter(Column("locallyDeleted") == false) + .fetchAll(db) + } + .publisher(in: Database.shared.dbQueue) + .catch { _ in Just([]) } + .sink { [weak self] rosters in + self?.actualRosters = rosters + } + } + + func resubscribeChats() { + let clientsJids = clients + .filter { $0.state != .disabled } + .map { $0.credentials.bareJid } + + chatsCancellable = ValueObservation.tracking { db in + try Chat + .filter(clientsJids.contains(Column("account"))) + .fetchAll(db) + } + .publisher(in: Database.shared.dbQueue) + .catch { _ in Just([]) } + .sink { [weak self] chats in + self?.actualChats = chats + } + } +} diff --git a/ConversationsClassic/AppData/Store/MessagesStore.swift b/ConversationsClassic/AppData/Store/MessagesStore.swift new file mode 100644 index 0000000..37635a2 --- /dev/null +++ b/ConversationsClassic/AppData/Store/MessagesStore.swift @@ -0,0 +1,149 @@ +import Combine +import Foundation +import GRDB +import Martin + +@MainActor +final class MessagesStore: ObservableObject { + @Published private(set) var messages: [Message] = [] + @Published var replyText = "" + + private(set) var roster: Roster + private let client: Client + + private var messagesCancellable: AnyCancellable? + private let archiver = ArchiveMessageFetcher() + + init(roster: Roster, client: Client) { + self.client = client + self.roster = roster + subscribe() + } +} + +// MARK: - Send message +extension MessagesStore { + func sendMessage(_ message: String) { + Task { + var msg = Message.blank + msg.from = roster.bareJid + msg.to = roster.contactBareJid + msg.body = message + + // store as pending on db, and send + do { + try await msg.save() + try await client.sendMessage(msg) + try await msg.setStatus(.sent) + } catch { + try? await msg.setStatus(.error) + } + } + } + + func sendContact(_ jidStr: String) { + sendMessage("contact:\(jidStr)") + } + + func sendLocation(_ lat: Double, _ lon: Double) { + sendMessage("geo:\(lat),\(lon)") + } +} + +// MARK: - Subscriptions +private extension MessagesStore { + func subscribe() { + messagesCancellable = ValueObservation.tracking(Message + .filter( + (Column("to") == roster.bareJid && Column("from") == roster.contactBareJid) || + (Column("from") == roster.bareJid && Column("to") == roster.contactBareJid) + ) + .order(Column("date").desc) + .fetchAll + ) + .publisher(in: Database.shared.dbQueue, scheduling: .immediate) + .receive(on: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] messages in + guard let self else { return } + self.messages = messages + Task { + await self.archiver.initialFetch(messages, self.roster, self.client) + } + } + } +} + +// MARK: - Archived messages +extension MessagesStore { + func scrolledMessage(_ messageId: String) { + if messageId == messages.last?.id { + Task { + await archiver.fetchBackward(roster, client) + } + } else if messageId == messages.first?.id { + Task { + await archiver.fetchForward(roster, client) + } + } + } +} + +private actor ArchiveMessageFetcher { + private var initFetchStarted = false + private var forwardRsm: RSM.Query? + private var backwardRsm: RSM.Query? + private var fetchInProgress = false + + func initialFetch(_ messages: [Message], _ roster: Roster, _ client: Client) async { + if initFetchStarted { return } + initFetchStarted = true + fetchInProgress = true + + do { + if let firstExistId = messages.first?.id { + let result = try await client.fetchArchiveMessages(for: roster, query: .init(before: firstExistId, max: Const.mamRequestPageSize)) + result.complete ? forwardRsm = nil : (forwardRsm = .init(after: result.rsm?.last, max: Const.mamRequestPageSize)) + result.complete ? backwardRsm = nil : (backwardRsm = .init(before: result.rsm?.first, max: Const.mamRequestPageSize)) + } else { + let result = try await client.fetchArchiveMessages(for: roster, query: .init(lastItems: Const.mamRequestPageSize)) + result.complete ? backwardRsm = nil : (backwardRsm = .init(before: result.rsm?.first, max: Const.mamRequestPageSize)) + } + } catch { + logIt(.error, "Error requesting archived messages: \(error)") + initFetchStarted = false + } + + fetchInProgress = false + } + + func fetchForward(_ roster: Roster, _ client: Client) async { + while !initFetchStarted { + await Task.yield() + } + guard let rsm = forwardRsm else { return } + if fetchInProgress { return } + + fetchInProgress = true + Task { + let result = try await client.fetchArchiveMessages(for: roster, query: rsm) + result.complete ? (forwardRsm = nil) : (forwardRsm = .init(after: result.rsm?.last, max: Const.mamRequestPageSize)) + fetchInProgress = false + } + } + + func fetchBackward(_ roster: Roster, _ client: Client) async { + while !initFetchStarted { + await Task.yield() + } + guard let rsm = backwardRsm else { return } + if fetchInProgress { return } + + fetchInProgress = true + Task { + let result = try await client.fetchArchiveMessages(for: roster, query: rsm) + result.complete ? (backwardRsm = nil) : (backwardRsm = .init(before: result.rsm?.first, max: Const.mamRequestPageSize)) + fetchInProgress = false + } + } +} diff --git a/ConversationsClassic/ConversationsClassicApp.swift b/ConversationsClassic/ConversationsClassicApp.swift index eff8cbd..bf24165 100644 --- a/ConversationsClassic/ConversationsClassicApp.swift +++ b/ConversationsClassic/ConversationsClassicApp.swift @@ -1,31 +1,21 @@ import Combine import SwiftUI -let appState = AppState() -let store = AppStore( - initialState: appState, - reducer: AppState.reducer, - middlewares: [ - loggerMiddleware(), - StartMiddleware.shared.middleware, - DatabaseMiddleware.shared.middleware, - AccountsMiddleware.shared.middleware, - XMPPMiddleware.shared.middleware, - RostersMiddleware.shared.middleware, - ChatsMiddleware.shared.middleware, - ArchivedMessagesMiddleware.shared.middleware, - ConversationMiddleware.shared.middleware, - SharingMiddleware.shared.middleware, - FileMiddleware.shared.middleware - ] -) - @main +@MainActor struct ConversationsClassic: App { + private let clientsStore = ClientsStore.shared + + init() { + // There's a bug on iOS 17 where sheet may not load with large title, even if modifiers are set, which causes some tests to fail + // https://stackoverflow.com/questions/77253122/swiftui-navigationstack-title-loads-inline-instead-of-large-when-sheet-is-pres + UINavigationBar.appearance().prefersLargeTitles = true + } + var body: some Scene { WindowGroup { - BaseNavigationView() - .environmentObject(store) + RootView() + .environmentObject(clientsStore) } } } diff --git a/ConversationsClassic/Helpers/AVAsset+Thumbnail.swift b/ConversationsClassic/Helpers/AVAsset+Thumbnail.swift new file mode 100644 index 0000000..3bccc0f --- /dev/null +++ b/ConversationsClassic/Helpers/AVAsset+Thumbnail.swift @@ -0,0 +1,16 @@ +import AVFoundation +import UIKit + +extension AVAsset { + func generateVideoThumbnail(_ size: CGSize) async throws -> UIImage { + try await Task { + let assetImgGenerate = AVAssetImageGenerator(asset: self) + assetImgGenerate.appliesPreferredTrackTransform = true + let time = CMTimeMakeWithSeconds(Float64(1), preferredTimescale: 600) + let cgImage = try assetImgGenerate.copyCGImage(at: time, actualTime: nil) + let image = UIImage(cgImage: cgImage) + let result = try await image.scaleAndCropImage(size) + return result + }.value + } +} diff --git a/ConversationsClassic/View/UIToolkit/Binding+Extensions.swift b/ConversationsClassic/Helpers/Binding+Extensions.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/Binding+Extensions.swift rename to ConversationsClassic/Helpers/Binding+Extensions.swift diff --git a/ConversationsClassic/Helpers/Bool+Extensions.swift b/ConversationsClassic/Helpers/Bool+Extensions.swift deleted file mode 100644 index b26674c..0000000 --- a/ConversationsClassic/Helpers/Bool+Extensions.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -extension Bool { - var intValue: Int { - self ? 1 : 0 - } -} diff --git a/ConversationsClassic/View/UIToolkit/ButtonStyles.swift b/ConversationsClassic/Helpers/ButtonStyles.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/ButtonStyles.swift rename to ConversationsClassic/Helpers/ButtonStyles.swift diff --git a/ConversationsClassic/View/UIToolkit/Colors+Tappable.swift b/ConversationsClassic/Helpers/Colors+Tappable.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/Colors+Tappable.swift rename to ConversationsClassic/Helpers/Colors+Tappable.swift diff --git a/ConversationsClassic/Helpers/Const.swift b/ConversationsClassic/Helpers/Const.swift index c96edfd..4c2d9f9 100644 --- a/ConversationsClassic/Helpers/Const.swift +++ b/ConversationsClassic/Helpers/Const.swift @@ -2,15 +2,6 @@ import Foundation import UIKit enum Const { - // // Network - // #if DEBUG - // static let baseUrl = "staging.some.com/api" - // #else - // static let baseUrl = "prod.some.com/api" - // #endif - // static let requestTimeout = 15.0 - // static let networkLogging = true - // App static var appVersion: String { let info = Bundle.main.infoDictionary @@ -32,9 +23,6 @@ enum Const { // Limit for video for sharing static let videoDurationLimit = 60.0 - // Upload/download file folder - static let fileFolder = "Downloads" - // Grid size for gallery preview (3 in a row) static let galleryGridSize = UIScreen.main.bounds.width / 3 @@ -44,10 +32,20 @@ enum Const { // Size for attachment preview static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5 - // Lenght in days for MAM request - static let mamRequestDaysLength = 30 - - // Limits for messages pagination - static let messagesPageMin = 20 - static let messagesPageMax = 100 + // MAM request page size + static let mamRequestPageSize = 50 +} + +final class FolderWrapper { + static let shared = FolderWrapper() + let fileFolder: URL + private init() { + // swiftlint:disable:next force_unwrapping + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let subdirectoryURL = documentsURL.appendingPathComponent("Downloads") + if !FileManager.default.fileExists(atPath: subdirectoryURL.path) { + try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + } + fileFolder = subdirectoryURL + } } diff --git a/ConversationsClassic/View/UIToolkit/EdgeInsets+Extensions.swift b/ConversationsClassic/Helpers/EdgeInsets+Extensions.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/EdgeInsets+Extensions.swift rename to ConversationsClassic/Helpers/EdgeInsets+Extensions.swift diff --git a/ConversationsClassic/Helpers/PHImageManager+Fetch.swift b/ConversationsClassic/Helpers/PHImageManager+Fetch.swift new file mode 100644 index 0000000..5bec4a8 --- /dev/null +++ b/ConversationsClassic/Helpers/PHImageManager+Fetch.swift @@ -0,0 +1,43 @@ +import Photos +import UIKit + +extension PHImageManager { + func getPhoto(for asset: PHAsset) async throws -> UIImage { + let options = PHImageRequestOptions() + options.version = .original + options.isSynchronous = true + return try await withCheckedThrowingContinuation { continuation in + requestImage( + for: asset, + targetSize: PHImageManagerMaximumSize, + contentMode: .aspectFill, + options: options + ) { image, _ in + if let image { + continuation.resume(returning: image) + } else { + continuation.resume(throwing: AppError.imageNotFound) + } + } + } + } + + func getVideo(for asset: PHAsset) async throws -> AVAsset { + let options = PHVideoRequestOptions() + options.version = .original + options.deliveryMode = .highQualityFormat + options.isNetworkAccessAllowed = true + return try await withCheckedThrowingContinuation { continuation in + requestAVAsset( + forVideo: asset, + options: options + ) { avAsset, _, _ in + if let avAsset { + continuation.resume(returning: avAsset) + } else { + continuation.resume(throwing: AppError.videoNotFound) + } + } + } + } +} diff --git a/ConversationsClassic/Helpers/Set+Extensions.swift b/ConversationsClassic/Helpers/Set+Extensions.swift deleted file mode 100644 index 910f33b..0000000 --- a/ConversationsClassic/Helpers/Set+Extensions.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -class ThreadSafeSet { - private var set: Set = [] - private let accessQueue = DispatchQueue(label: "com.example.ThreadSafeSet") - - func insert(_ newElement: T) { - _ = accessQueue.sync { - set.insert(newElement) - } - } - - func remove(_ element: T) { - _ = accessQueue.sync { - set.remove(element) - } - } - - var elements: Set { - accessQueue.sync { set } - } - - func contains(_ element: T) -> Bool { - accessQueue.sync { set.contains(element) } - } -} diff --git a/ConversationsClassic/Helpers/String+Extensions.swift b/ConversationsClassic/Helpers/String+Extensions.swift index 1bbdc25..5701e61 100644 --- a/ConversationsClassic/Helpers/String+Extensions.swift +++ b/ConversationsClassic/Helpers/String+Extensions.swift @@ -37,12 +37,12 @@ extension String { } extension String { - var attachmentType: MessageAttachmentType { + var attachmentType: AttachmentType { let ext = (self as NSString).pathExtension.lowercased() switch ext { case "mov", "mp4", "avi": - return .movie + return .video case "jpg", "png", "gif": return .image diff --git a/ConversationsClassic/View/UIToolkit/Typography.swift b/ConversationsClassic/Helpers/Typography.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/Typography.swift rename to ConversationsClassic/Helpers/Typography.swift diff --git a/ConversationsClassic/Helpers/UIImage+Crop.swift b/ConversationsClassic/Helpers/UIImage+Crop.swift new file mode 100644 index 0000000..239a1e8 --- /dev/null +++ b/ConversationsClassic/Helpers/UIImage+Crop.swift @@ -0,0 +1,30 @@ +import Foundation +import UIKit + +extension UIImage { + func scaleAndCropImage(_ size: CGSize) async throws -> UIImage { + try await Task { + let aspect = self.size.width / self.size.height + let targetAspect = size.width / size.height + var newWidth: CGFloat + var newHeight: CGFloat + if aspect < targetAspect { + newWidth = size.width + newHeight = size.width / aspect + } else { + newHeight = size.height + newWidth = size.height * aspect + } + + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + self.draw(in: CGRect(x: (size.width - newWidth) / 2, y: (size.height - newHeight) / 2, width: newWidth, height: newHeight)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + if let newImage = newImage { + return newImage + } else { + throw NSError(domain: "UIImage", code: -900, userInfo: nil) + } + }.value + } +} diff --git a/ConversationsClassic/View/UIToolkit/Vibration.swift b/ConversationsClassic/Helpers/Vibration.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/Vibration.swift rename to ConversationsClassic/Helpers/Vibration.swift diff --git a/ConversationsClassic/View/UIToolkit/View+Debug.swift b/ConversationsClassic/Helpers/View+Debug.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/View+Debug.swift rename to ConversationsClassic/Helpers/View+Debug.swift diff --git a/ConversationsClassic/Helpers/View+Flip.swift b/ConversationsClassic/Helpers/View+Flip.swift new file mode 100644 index 0000000..e0d0df2 --- /dev/null +++ b/ConversationsClassic/Helpers/View+Flip.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct FlipView: ViewModifier { + func body(content: Content) -> some View { + content + .rotationEffect(.radians(Double.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + } +} + +extension View { + func flip() -> some View { + modifier(FlipView()) + } +} diff --git a/ConversationsClassic/View/UIToolkit/View+If.swift b/ConversationsClassic/Helpers/View+If.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/View+If.swift rename to ConversationsClassic/Helpers/View+If.swift diff --git a/ConversationsClassic/View/UIToolkit/View+OnLoad.swift b/ConversationsClassic/Helpers/View+OnLoad.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/View+OnLoad.swift rename to ConversationsClassic/Helpers/View+OnLoad.swift diff --git a/ConversationsClassic/View/UIToolkit/View+TappableArea.swift b/ConversationsClassic/Helpers/View+TappableArea.swift similarity index 100% rename from ConversationsClassic/View/UIToolkit/View+TappableArea.swift rename to ConversationsClassic/Helpers/View+TappableArea.swift diff --git a/ConversationsClassic/Resources/Strings/Localizable.strings b/ConversationsClassic/Resources/Strings/Localizable.strings index 8458afc..5f97a53 100644 --- a/ConversationsClassic/Resources/Strings/Localizable.strings +++ b/ConversationsClassic/Resources/Strings/Localizable.strings @@ -5,64 +5,50 @@ "Global.cancel" = "Cancel"; "Global.save" = "Save"; "Global.Error.title" = "Error"; -"Global.Error.genericText" = "Something went wrong"; -"Global.Error.genericDbError" = "Database error"; -// MARK: Onboar screen +// MARK: Welcome screen "Start.subtitle" = "Free and secure messaging and calls between any existed messengers"; "Start.Btn.login" = "Enter with JID"; "Start.Btn.register" = "New Account"; + +// MARK: Login "Login.title" = "Let\'s go!"; "Login.subtitle" = "Enter your JID, it should looks like email address"; "Login.Hint.jid" = "user@domain.im"; "Login.Hint.password" = "password"; "Login.btn" = "Continue"; -"Login.Error.wrongPassword" = "Wrong password or JID"; -"Login.Error.noServer" = "Server not exists"; -"Login.Error.serverError" = "Server error. Check internet connection"; +"Login.error" = "Check internet connection, and make sure that JID and password are correct"; + +// MARK: Tabs +"Tabs.Name.contacts" = "Contacts"; +"Tabs.Name.conversations" = "Chats"; +"Tabs.Name.settings" = "Settings"; // MARK: Contacts screen "Contacts.title" = "Contacts"; + + +// MARK: Add contact/channel screen +"Contacts.Add.title" = "Add Contact"; +"Contacts.Add.explanation" = "Contact or group/channel name are usually JID in format name@domain.ltd (like email)"; +"Contacts.Add.serverError" = "Contact adding dailed. Server returned error"; +"Contacts.deleteContact" = "Delete contact"; +"Contacts.Delete.deleteFromDevice" = "Delete from device"; +"Contacts.Delete.deleteCompletely" = "Delete completely"; "Contacts.sendMessage" = "Send message"; "Contacts.editContact" = "Edit contact"; "Contacts.selectContact" = "Select contact"; -"Contacts.deleteContact" = "Delete contact"; -"Contacts.Add.title" = "Add Contact"; -"Contacts.Add.explanation" = "Contact or group/channel name are usually JID in format name@domain.ltd (like email)"; -"Contacts.Add.error" = "Contact not added. Server returns error."; -"Contacts.Delete.title" = "Delete contact"; "Contacts.Delete.message" = "You can delete contact from this device (contact will be available on other devices), or delete it completely"; -"Contacts.Delete.deleteFromDevice" = "Delete from device"; -"Contacts.Delete.deleteCompletely" = "Delete completely"; "Contacts.Delete.error" = "Contact not deleted. Server returns error."; - -// MARK: Chats screen -"Chats.title" = "Chats"; - -"Chat.title" = "Chat"; -"Chat.textfieldPrompt" = "Type a message"; - +// MARK: Chats list screen +"ChatsList.title" = "Chats"; "Chats.Create.Main.title" = "Create"; -"Chats.Create.Main.createGroup" = "Create public group"; -"Chats.Create.Main.createPrivateGroup" = "Create private group"; -"Chats.Create.Main.findGroup" = "Find public group"; -// MARK: Accounts add screen -"Accounts.Add.or" = "or"; -"Accounts.Add.Exist.title" = "Add existing\naccount"; -"Accounts.Add.Exist.Prompt.jid" = "Enter your XMPP ID"; -"Accounts.Add.Exist.Prompt.password" = "Enter password"; -"Accounts.Add.Exist.Hint.jid" = "user@domain.im"; -"Accounts.Add.Exist.Hint.password" = "password"; -"Accounts.Add.Exist.Btn.link" = "create a new one"; -"Accounts.Add.Exist.Btn.main" = "Continue"; -"Accounts.Add.Exist.loginError" = "Wrong login or password"; - -// MARK: Server connecting indicator -"ServerConnectingIndicator.State.connecting" = "Connecting to server"; -"ServerConnectingIndicator.State.connected" = "Connected"; -"ServerConnectingIndicator.State.error" = "Server unreachable. Check internet connection and server name"; +// MARK: Conversation +"Conversation.title" = "Conversation"; +"Conversation.startError" = "Error occurs in conversation starting"; +"Chat.textfieldPrompt" = "Type a message"; // MARK: Attachments "Attachment.Prompt.main" = "Select attachment"; @@ -74,3 +60,16 @@ "Attachment.Send.location" = "Send location"; "Attachment.Send.contact" = "Send contact"; "Attachment.Downloading.retry" = "Retry"; + + + + + + + +//"Chats.Create.Main.createGroup" = "Create public group"; +//"Chats.Create.Main.createPrivateGroup" = "Create private group"; +//"Chats.Create.Main.findGroup" = "Find public group"; + + + diff --git a/ConversationsClassic/View/BaseNavigationView.swift b/ConversationsClassic/View/BaseNavigationView.swift deleted file mode 100644 index 558b471..0000000 --- a/ConversationsClassic/View/BaseNavigationView.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Martin -import SwiftUI - -struct BaseNavigationView: View { - @EnvironmentObject var store: AppStore - - public var body: some View { - Group { - switch store.state.currentFlow { - case .start: - switch store.state.startState.navigation { - case .startScreen: - StartScreen() - - case .welcomeScreen: - WelcomeScreen() - } - - case .accounts: - switch store.state.accountsState.navigation { - case .addAccount: - AddAccountScreen() - } - - case .chats: - ChatsListScreen() - - case .contacts: - ContactsScreen() - - case .settings: - SettingsScreen() - - case .conversation: - ConversationScreen() - } - } - } -} diff --git a/ConversationsClassic/View/Screens/AddAccountScreen.swift b/ConversationsClassic/View/Entering/LoginScreen.swift similarity index 74% rename from ConversationsClassic/View/Screens/AddAccountScreen.swift rename to ConversationsClassic/View/Entering/LoginScreen.swift index 1648429..596bd1c 100644 --- a/ConversationsClassic/View/Screens/AddAccountScreen.swift +++ b/ConversationsClassic/View/Entering/LoginScreen.swift @@ -2,8 +2,9 @@ import Combine import Martin import SwiftUI -struct AddAccountScreen: View { - @EnvironmentObject var store: AppStore +struct LoginScreen: View { + @Environment(\.router) var router + @EnvironmentObject var clientsStore: ClientsStore enum Field { case userJid @@ -11,9 +12,6 @@ struct AddAccountScreen: View { } @FocusState private var focus: Field? - @State private var errorMsg: String = "" - @State private var isShowingAlert = false - @State private var isShowingLoader = false #if DEBUG @State private var jidStr: String = "nartest1@conversations.im" @@ -77,8 +75,12 @@ struct AddAccountScreen: View { ) Button { - isShowingLoader = true - store.dispatch(.accountsAction(.tryAddAccountWithCredentials(login: jidStr, password: pass))) + router.showModal { + LoadingScreen() + } + Task(priority: .background) { + await tryLogin() + } } label: { Text(L10n.Login.btn) } @@ -86,8 +88,7 @@ struct AddAccountScreen: View { .disabled(!loginInputValid) Button { - store.dispatch(.startAction(.goTo(.welcomeScreen))) - store.dispatch(.changeFlow(.start)) + router.dismissScreen() } label: { Text("\(Image(systemName: "chevron.left")) \(L10n.Global.back)") .foregroundColor(.Material.Elements.active) @@ -97,26 +98,27 @@ struct AddAccountScreen: View { } .padding(.horizontal, 32) } - .loadingIndicator(isShowingLoader) - .alert(isPresented: $isShowingAlert) { - Alert( - title: Text(L10n.Global.Error.title), - message: Text(errorMsg), - dismissButton: .default(Text(L10n.Global.ok)) { - store.dispatch(.accountsAction(.addAccountError(jid: jidStr, reason: nil))) - } - ) - } - .onChange(of: store.state.accountsState.addAccountError) { err in - if let err { - isShowingLoader = false - isShowingAlert = true - errorMsg = err - } - } } private var loginInputValid: Bool { !jidStr.isEmpty && !pass.isEmpty && UniversalInputCollection.Validators.isEmail(jidStr) } + + private func tryLogin() async { + defer { + router.dismissModal() + } + + do { + try await clientsStore.tryLogin(jidStr, pass) + } catch { + router.showAlert( + .alert, + title: L10n.Global.Error.title, + subtitle: L10n.Login.error + ) { + Button(L10n.Global.ok, role: .cancel) {} + } + } + } } diff --git a/ConversationsClassic/View/Screens/RegistrationScreen.swift b/ConversationsClassic/View/Entering/RegistrationScreen.swift similarity index 81% rename from ConversationsClassic/View/Screens/RegistrationScreen.swift rename to ConversationsClassic/View/Entering/RegistrationScreen.swift index ccd4d7f..16b095d 100644 --- a/ConversationsClassic/View/Screens/RegistrationScreen.swift +++ b/ConversationsClassic/View/Entering/RegistrationScreen.swift @@ -1,13 +1,13 @@ import SwiftUI struct RegistrationScreen: View { - // @EnvironmentObject var state: AppState + @Environment(\.router) var router public var body: some View { ZStack { Color.Material.Background.light Button { - // state.flow = .welcome + router.dismissScreen() } label: { VStack { Text("Not yet implemented") diff --git a/ConversationsClassic/View/Screens/WelcomeScreen.swift b/ConversationsClassic/View/Entering/WelcomeScreen.swift similarity index 76% rename from ConversationsClassic/View/Screens/WelcomeScreen.swift rename to ConversationsClassic/View/Entering/WelcomeScreen.swift index d7a27f7..cbe008f 100644 --- a/ConversationsClassic/View/Screens/WelcomeScreen.swift +++ b/ConversationsClassic/View/Entering/WelcomeScreen.swift @@ -1,9 +1,9 @@ import SwiftUI struct WelcomeScreen: View { - @EnvironmentObject var store: AppStore + @Environment(\.router) var router - public var body: some View { + var body: some View { ZStack { // background Color.Material.Background.light @@ -33,14 +33,19 @@ struct WelcomeScreen: View { // buttons VStack(spacing: 16) { Button { - store.dispatch(.accountsAction(.goTo(.addAccount))) - store.dispatch(.changeFlow(.accounts)) + router.showScreen(.push) { _ in + LoginScreen() + .navigationBarBackButtonHidden(true) + } } label: { Text(L10n.Start.Btn.login) } .buttonStyle(SecondaryButtonStyle()) Button { - // state.flow = .registration + router.showScreen(.push) { _ in + RegistrationScreen() + .navigationBarBackButtonHidden(true) + } } label: { Text(L10n.Start.Btn.register) } diff --git a/ConversationsClassic/View/Screens/Chats/ChatsCreateMainScreen.swift b/ConversationsClassic/View/Main/ChatList/ChatsCreateScreenMain.swift similarity index 79% rename from ConversationsClassic/View/Screens/Chats/ChatsCreateMainScreen.swift rename to ConversationsClassic/View/Main/ChatList/ChatsCreateScreenMain.swift index c25b8f8..e82ff73 100644 --- a/ConversationsClassic/View/Screens/Chats/ChatsCreateMainScreen.swift +++ b/ConversationsClassic/View/Main/ChatList/ChatsCreateScreenMain.swift @@ -1,7 +1,7 @@ import SwiftUI -struct ChatsCreateMainScreen: View { - @Binding var isPresented: Bool +struct ChatsCreateScreenMain: View { + @Environment(\.router) var router var body: some View { ZStack { @@ -16,7 +16,7 @@ struct ChatsCreateMainScreen: View { leftButton: .init( image: Image(systemName: "xmark"), action: { - isPresented = false + router.dismissScreen() } ), centerText: .init(text: L10n.Chats.Create.Main.title) @@ -24,6 +24,7 @@ struct ChatsCreateMainScreen: View { // List List { + Text("test") // ChatsCreateRowButton( // title: L10n.Chats.Create.Main.createGroup, // image: "person.2.fill", @@ -41,10 +42,10 @@ struct ChatsCreateMainScreen: View { // ) // for contacts list - let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted } - if rosters.isEmpty { - ChatsCreateRowSeparator() - } + // let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted } + // if rosters.isEmpty { + // ChatsCreateRowSeparator() + // } } .listStyle(.plain) @@ -157,26 +158,26 @@ private struct ChatsCreateRowSeparator: View { // // Preview -#if DEBUG - struct ChatsCreateMainScreen_Previews: PreviewProvider { - static var previews: some View { - ChatsCreateMainScreen(isPresented: .constant(true)) - .environmentObject(pStore) - } - - static var pStore: AppStore { - let state = pState - return AppStore(initialState: state, reducer: AppState.reducer, middlewares: []) - } - - static var pState: AppState { - var state = AppState() - - state.rostersState.rosters = [ - .init(contactBareJid: "test@me.com", subscription: "both", ask: true, data: .init(groups: [], annotations: [])) - ] - - return state - } - } -#endif +// #if DEBUG +// struct ChatsCreateMainScreen_Previews: PreviewProvider { +// static var previews: some View { +// ChatsCreateMainScreen(isPresented: .constant(true)) +// .environmentObject(pStore) +// } +// +// static var pStore: AppStore { +// let state = pState +// return AppStore(initialState: state, reducer: AppState.reducer, middlewares: []) +// } +// +// static var pState: AppState { +// var state = AppState() +// +// state.rostersState.rosters = [ +// .init(contactBareJid: "test@me.com", subscription: "both", ask: true, data: .init(groups: [], annotations: [])) +// ] +// +// return state +// } +// } +// #endif diff --git a/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift b/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift new file mode 100644 index 0000000..cfae0c1 --- /dev/null +++ b/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct ChatsListScreen: View { + @Environment(\.router) var router + @EnvironmentObject var clientsStore: ClientsStore + + var body: some View { + ZStack { + // Background color + Color.Material.Background.light + .ignoresSafeArea() + + // Content + VStack(spacing: 0) { + // Header + SharedNavigationBar( + centerText: .init(text: L10n.ChatsList.title), + rightButton: .init( + image: Image(systemName: "square.and.pencil"), + action: { + router.showScreen(.fullScreenCover) { _ in + ChatsCreateScreenMain() + } + } + ) + ) + + // Chats list + if !clientsStore.actualChats.isEmpty { + List { + ForEach(clientsStore.actualChats) { chat in + ChatsRow(chat: chat) + } + } + .listStyle(.plain) + .background(Color.Material.Background.light) + } else { + Spacer() + } + } + } + } +} + +private struct ChatsRow: View { + @Environment(\.router) var router + @EnvironmentObject var clientsStore: ClientsStore + + var chat: Chat + + var body: some View { + SharedListRow(iconType: .charCircle(chat.participant), text: chat.participant) + .onTapGesture { + Task { + router.showModal { + LoadingScreen() + } + defer { + router.dismissModal() + } + + do { + try? await clientsStore.addRosterForNewChatIfNeeded(chat) + let (messages, attachments) = try await clientsStore.conversationStores(for: chat) + router.showScreen(.push) { _ in + ConversationScreen(messagesStore: messages, attachments: attachments) + .navigationBarHidden(true) + } + } catch { + router.showAlert( + .alert, + title: L10n.Global.Error.title, + subtitle: L10n.Conversation.startError + ) { + Button(L10n.Global.ok, role: .cancel) {} + } + } + } + } + } +} diff --git a/ConversationsClassic/View/Screens/Contacts/AddContactOrChannelScreen.swift b/ConversationsClassic/View/Main/Contacts/AddContactOrChannelScreen.swift similarity index 62% rename from ConversationsClassic/View/Screens/Contacts/AddContactOrChannelScreen.swift rename to ConversationsClassic/View/Main/Contacts/AddContactOrChannelScreen.swift index 56897f7..c756d39 100644 --- a/ConversationsClassic/View/Screens/Contacts/AddContactOrChannelScreen.swift +++ b/ConversationsClassic/View/Main/Contacts/AddContactOrChannelScreen.swift @@ -1,7 +1,8 @@ import SwiftUI struct AddContactOrChannelScreen: View { - @EnvironmentObject var store: AppStore + @Environment(\.router) var router + @EnvironmentObject var clientsStore: ClientsStore enum Field { case account @@ -10,13 +11,8 @@ struct AddContactOrChannelScreen: View { @FocusState private var focus: Field? - @Binding var isPresented: Bool + @State private var ownerCredentials: Credentials? @State private var contactJID: String = "" - @State private var ownerAccount: Account? - - @State private var isShowingLoader = false - @State private var isShowingAlert = false - @State private var errorMsg = "" var body: some View { ZStack { @@ -31,7 +27,7 @@ struct AddContactOrChannelScreen: View { leftButton: .init( image: Image(systemName: "xmark"), action: { - isPresented = false + router.dismissScreen() } ), centerText: .init(text: L10n.Contacts.Add.title), @@ -43,9 +39,9 @@ struct AddContactOrChannelScreen: View { ) ) + // Content VStack(spacing: 16) { // Explanation text - Text(L10n.Contacts.Add.explanation) .font(.body3) .foregroundColor(.Material.Shape.separator) @@ -62,8 +58,8 @@ struct AddContactOrChannelScreen: View { } UniversalInputCollection.DropDownMenu( prompt: "Use account", - elements: store.state.accountsState.accounts, - selected: $ownerAccount, + elements: activeClientsCredentials, + selected: $ownerCredentials, focus: $focus, fieldType: .account ) @@ -91,7 +87,9 @@ struct AddContactOrChannelScreen: View { // Save button Button { - save() + Task { + await save() + } } label: { Text(L10n.Global.save) } @@ -104,45 +102,44 @@ struct AddContactOrChannelScreen: View { } } .onAppear { - if let exists = store.state.accountsState.accounts.first, exists.isActive { - ownerAccount = exists - } - } - .loadingIndicator(isShowingLoader) - .alert(isPresented: $isShowingAlert) { - Alert( - title: Text(L10n.Global.Error.title), - message: Text(errorMsg), - dismissButton: .default(Text(L10n.Global.ok)) - ) - } - .onChange(of: store.state.rostersState.newAddedRosterJid) { jid in - if jid != nil, isShowingLoader { - isShowingLoader = false - isPresented = false - } - } - .onChange(of: store.state.rostersState.newAddedRosterError) { error in - if let error = error, isShowingLoader { - isShowingLoader = false - errorMsg = error - isShowingAlert = true + if let exists = activeClientsCredentials.first { + ownerCredentials = exists } } } + private var activeClientsCredentials: [Credentials] { + clientsStore.clients + .filter { $0.state != .disabled } + .map { $0.credentials } + } + private var inputValid: Bool { - ownerAccount != nil && !contactJID.isEmpty && UniversalInputCollection.Validators.isEmail(contactJID) + ownerCredentials != nil && !contactJID.isEmpty && UniversalInputCollection.Validators.isEmail(contactJID) } - private func save() { - guard let ownerAccount else { return } - if let exists = store.state.rostersState.rosters.first(where: { $0.bareJid == ownerAccount.bareJid && $0.contactBareJid == contactJID }), exists.locallyDeleted { - store.dispatch(.rostersAction(.unmarkRosterAsLocallyDeleted(ownerJID: ownerAccount.bareJid, contactJID: contactJID))) - isPresented = false - } else { - isShowingLoader = true - store.dispatch(.rostersAction(.addRoster(ownerJID: ownerAccount.bareJid, contactJID: contactJID, name: nil, groups: []))) + private func save() async { + guard let ownerCredentials = ownerCredentials else { return } + + router.showModal { + LoadingScreen() + } + + defer { + router.dismissModal() + } + + do { + try await clientsStore.addRoster(ownerCredentials, contactJID: contactJID, name: nil, groups: []) + router.dismissScreen() + } catch { + router.showAlert( + .alert, + title: L10n.Global.Error.title, + subtitle: L10n.Contacts.Add.serverError + ) { + Button(L10n.Global.ok, role: .cancel) {} + } } } } diff --git a/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift b/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift new file mode 100644 index 0000000..be31e01 --- /dev/null +++ b/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift @@ -0,0 +1,177 @@ +import SwiftUI + +struct ContactsScreen: View { + @Environment(\.router) var router + @EnvironmentObject var clientsStore: ClientsStore + + var body: some View { + ZStack { + // Background color + Color.Material.Background.light + .ignoresSafeArea() + + // Content + VStack(spacing: 0) { + // Header + SharedNavigationBar( + centerText: .init(text: L10n.Contacts.title), + rightButton: .init( + image: Image(systemName: "plus"), + action: { + router.showScreen(.fullScreenCover) { _ in + AddContactOrChannelScreen() + } + } + ) + ) + + // Contacts list + if !clientsStore.actualRosters.isEmpty { + List { + ForEach(clientsStore.actualRosters) { roster in + ContactsScreenRow( + roster: roster + ) + } + } + .listStyle(.plain) + .background(Color.Material.Background.light) + } else { + Spacer() + } + } + } + } +} + +private struct ContactsScreenRow: View { + @Environment(\.router) var router + @EnvironmentObject var clientsStore: ClientsStore + + var roster: Roster + + var body: some View { + SharedListRow( + iconType: .charCircle(roster.name?.firstLetter ?? roster.contactBareJid.firstLetter), + text: roster.contactBareJid + ) + .onTapGesture { + startChat() + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + router.showAlert(.confirmationDialog, title: L10n.Contacts.deleteContact, subtitle: L10n.Contacts.Delete.message) { + deleteConfirmation + } + } label: { + Label("", systemImage: "trash") + } + .tint(Color.red) + } + .contextMenu { + Button(L10n.Contacts.sendMessage, systemImage: "message") { + startChat() + } + Divider() + + Button(L10n.Contacts.editContact) { + print("Edit contact") + } + + Button(L10n.Contacts.selectContact) { + print("Select contact") + } + + Divider() + Button(L10n.Contacts.deleteContact, systemImage: "trash", role: .destructive) { + router.showAlert(.confirmationDialog, title: L10n.Contacts.deleteContact, subtitle: L10n.Contacts.Delete.message) { + deleteConfirmation + } + } + } + } + + @ViewBuilder private var deleteConfirmation: some View { + Button(role: .destructive) { + Task { + await deleteFromDevice() + } + } label: { + Text(L10n.Contacts.Delete.deleteFromDevice) + } + + Button(role: .destructive) { + Task { + await deleteCompletely() + } + } label: { + Text(L10n.Contacts.Delete.deleteCompletely) + } + + Button(role: .cancel) {} label: { + Text(L10n.Global.cancel) + } + } + + private func deleteFromDevice() async { + router.showModal { + LoadingScreen() + } + + defer { + router.dismissModal() + } + + var roster = roster + try? await roster.setLocallyDeleted(true) + } + + private func deleteCompletely() async { + router.showModal { + LoadingScreen() + } + + defer { + router.dismissModal() + } + + do { + try await clientsStore.deleteRoster(roster) + } catch { + router.showAlert( + .alert, + title: L10n.Global.Error.title, + subtitle: L10n.Contacts.Delete.error + ) { + Button(L10n.Global.ok, role: .cancel) {} + } + } + } + + private func startChat() { + Task { + router.showModal { + LoadingScreen() + } + defer { + router.dismissModal() + } + + do { + let (messages, attachments) = try await clientsStore.conversationStores(for: roster) + router.showScreen(.push) { _ in + ConversationScreen(messagesStore: messages, attachments: attachments) + .navigationBarHidden(true) + } + } catch { + router.showAlert( + .alert, + title: L10n.Global.Error.title, + subtitle: L10n.Conversation.startError + ) { + Button(L10n.Global.ok, role: .cancel) {} + } + } + } + } +} diff --git a/ConversationsClassic/View/Screens/Sharing/SharingTabBar.swift b/ConversationsClassic/View/Main/Conversation/Attachments/AttachmentPickerScreen.swift similarity index 52% rename from ConversationsClassic/View/Screens/Sharing/SharingTabBar.swift rename to ConversationsClassic/View/Main/Conversation/Attachments/AttachmentPickerScreen.swift index 770dc04..8c57bd4 100644 --- a/ConversationsClassic/View/Screens/Sharing/SharingTabBar.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/AttachmentPickerScreen.swift @@ -1,14 +1,60 @@ import SwiftUI -enum SharingTab: Int, CaseIterable { +enum AttachmentTab: Int, CaseIterable { case media case files case location case contacts } -struct SharingTabBar: View { - @Binding var selectedTab: SharingTab +struct AttachmentPickerScreen: View { + @Environment(\.router) var router + + @State private var selectedTab: AttachmentTab = .media + + var body: some View { + ZStack { + // Background color + Color.Material.Background.light + .ignoresSafeArea() + + // Content + VStack(spacing: 0) { + // Header + SharedNavigationBar( + leftButton: .init( + image: Image(systemName: "xmark"), + action: { + router.dismissScreen() + } + ), + centerText: .init(text: L10n.Attachment.Prompt.main) + ) + + // Pickers + switch selectedTab { + case .media: + MediaPickerView() + + case .files: + FilesPickerView() + + case .location: + LocationPickerView() + + case .contacts: + ContactsPickerView() + } + + // Tab bar + AttachmentTabBar(selectedTab: $selectedTab) + } + } + } +} + +struct AttachmentTabBar: View { + @Binding var selectedTab: AttachmentTab var body: some View { VStack(spacing: 0) { @@ -17,10 +63,10 @@ struct SharingTabBar: View { .frame(height: 0.2) .foregroundColor(.Material.Shape.separator) HStack(spacing: 0) { - SharingTabBarButton(tab: .media, selected: $selectedTab) - SharingTabBarButton(tab: .files, selected: $selectedTab) - SharingTabBarButton(tab: .location, selected: $selectedTab) - SharingTabBarButton(tab: .contacts, selected: $selectedTab) + AttachmentTabBarButton(tab: .media, selected: $selectedTab) + AttachmentTabBarButton(tab: .files, selected: $selectedTab) + AttachmentTabBarButton(tab: .location, selected: $selectedTab) + AttachmentTabBarButton(tab: .contacts, selected: $selectedTab) } .background(Color.Material.Background.dark) } @@ -28,9 +74,9 @@ struct SharingTabBar: View { } } -private struct SharingTabBarButton: View { - let tab: SharingTab - @Binding var selected: SharingTab +private struct AttachmentTabBarButton: View { + let tab: AttachmentTab + @Binding var selected: AttachmentTab var body: some View { ZStack { diff --git a/ConversationsClassic/View/Screens/Sharing/SharingContactsPickerView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/ContactsPickerView.swift similarity index 83% rename from ConversationsClassic/View/Screens/Sharing/SharingContactsPickerView.swift rename to ConversationsClassic/View/Main/Conversation/Attachments/ContactsPickerView.swift index 8c7588e..ddb0fa6 100644 --- a/ConversationsClassic/View/Screens/Sharing/SharingContactsPickerView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/ContactsPickerView.swift @@ -1,13 +1,15 @@ import SwiftUI -struct SharingContactsPickerView: View { - @EnvironmentObject var store: AppStore +struct ContactsPickerView: View { + @Environment(\.router) var router + @EnvironmentObject var messages: MessagesStore + + @State private var rosters: [Roster] = [] @State private var selectedContact: Roster? var body: some View { VStack(spacing: 0) { // Contacts list - let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted } if !rosters.isEmpty { List { ForEach(rosters) { roster in @@ -40,11 +42,14 @@ struct SharingContactsPickerView: View { .clipped() .onTapGesture { if let selectedContact = selectedContact { - store.dispatch(.sharingAction(.shareContact(jid: selectedContact.contactBareJid))) - store.dispatch(.sharingAction(.showSharing(false))) + messages.sendContact(selectedContact.contactBareJid) + router.dismissEnvironment() } } } + .task { + rosters = await Roster.allActive + } } } diff --git a/ConversationsClassic/View/Screens/Sharing/SharingFilesPickerView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/FilesPickerView.swift similarity index 85% rename from ConversationsClassic/View/Screens/Sharing/SharingFilesPickerView.swift rename to ConversationsClassic/View/Main/Conversation/Attachments/FilesPickerView.swift index 5535b02..01e4959 100644 --- a/ConversationsClassic/View/Screens/Sharing/SharingFilesPickerView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/FilesPickerView.swift @@ -1,17 +1,18 @@ import SwiftUI import UIKit -struct SharingFilesPickerView: View { - @EnvironmentObject var store: AppStore +struct FilesPickerView: View { + @Environment(\.router) var router + @EnvironmentObject var attachments: AttachmentsStore var body: some View { DocumentPicker( completion: { dataArray, extensionsArray in - store.dispatch(.sharingAction(.showSharing(false))) - store.dispatch(.sharingAction(.shareDocuments(dataArray, extensionsArray))) + attachments.sendDocuments(dataArray, extensionsArray) + router.dismissEnvironment() }, cancel: { - store.dispatch(.sharingAction(.showSharing(false))) + router.dismissEnvironment() } ) } diff --git a/ConversationsClassic/View/Screens/Sharing/SharingLocationPickerView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/LocationPickerView.swift similarity index 94% rename from ConversationsClassic/View/Screens/Sharing/SharingLocationPickerView.swift rename to ConversationsClassic/View/Main/Conversation/Attachments/LocationPickerView.swift index def4bff..5b90474 100644 --- a/ConversationsClassic/View/Screens/Sharing/SharingLocationPickerView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/LocationPickerView.swift @@ -1,7 +1,10 @@ import MapKit import SwiftUI -struct SharingLocationPickerView: View { +struct LocationPickerView: View { + @Environment(\.router) var router + @EnvironmentObject var messages: MessagesStore + @StateObject var locationManager = LocationManager() @State private var region = MKCoordinateRegion() @@ -62,8 +65,8 @@ struct SharingLocationPickerView: View { } .clipped() .onTapGesture { - store.dispatch(.sharingAction(.shareLocation(lat: region.center.latitude, lon: region.center.longitude))) - store.dispatch(.sharingAction(.showSharing(false))) + messages.sendLocation(region.center.latitude, region.center.longitude) + router.dismissEnvironment() } } .onAppear { diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraCellPreview.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraCellPreview.swift new file mode 100644 index 0000000..a9a3dbe --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraCellPreview.swift @@ -0,0 +1,60 @@ +import AVFoundation +import SwiftUI + +struct CameraCellPreview: View { + @Environment(\.router) var router + @EnvironmentObject var attachments: AttachmentsStore + + var body: some View { + Group { + if attachments.cameraAccessGranted { + ZStack { + CameraView() + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + Image(systemName: "camera") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .foregroundColor(.white) + .padding(8) + .background(Color.black.opacity(0.5)) + .clipShape(Circle()) + .padding(8) + } + .onTapGesture { + router.showScreen(.fullScreenCover) { _ in + CameraPicker { data, type in + attachments.sendCaptured(data, type) + router.dismissEnvironment() + } + .ignoresSafeArea(.all) + } + } + } else { + Button { + openAppSettings() + } label: { + ZStack { + Rectangle() + .fill(Color.Material.Background.light) + .overlay { + VStack { + Image(systemName: "camera") + .foregroundColor(.Material.Elements.active) + .font(.system(size: 30)) + Text("Allow camera access") + .foregroundColor(.Material.Text.main) + .font(.body3) + } + } + .frame(height: 100) + } + } + } + } + .task { + await attachments.checkCameraAuthorization() + } + } +} diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraPicker.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraPicker.swift new file mode 100644 index 0000000..b97ee54 --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraPicker.swift @@ -0,0 +1,49 @@ +import Foundation +import Photos +import SwiftUI + +struct CameraPicker: UIViewControllerRepresentable { + var completionHandler: (Data, GalleryMediaType) -> Void + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = context.coordinator + picker.mediaTypes = [UTType.movie.identifier, UTType.image.identifier] + picker.videoQuality = .typeHigh + picker.videoMaximumDuration = Const.videoDurationLimit + picker.view.backgroundColor = .clear + return picker + } + + func updateUIViewController(_: UIImagePickerController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraPicker + + init(_ parent: CameraPicker) { + self.parent = parent + } + + func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + // swiftlint:disable:next force_cast + let mediaType = info[.mediaType] as! String + + if mediaType == UTType.image.identifier { + if let image = info[.originalImage] as? UIImage { + let data = image.jpegData(compressionQuality: 1.0) ?? Data() + parent.completionHandler(data, .photo) + } + } else if mediaType == UTType.movie.identifier { + if let url = info[.mediaURL] as? URL { + let data = try? Data(contentsOf: url) + parent.completionHandler(data ?? Data(), .video) + } + } + } + } +} diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraView.swift new file mode 100644 index 0000000..e7261fd --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/CameraView.swift @@ -0,0 +1,38 @@ +import AVFoundation +import SwiftUI +import UIKit + +class CameraUIView: UIView { + var previewLayer: AVCaptureVideoPreviewLayer? + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } +} + +struct CameraView: UIViewRepresentable { + func makeUIView(context _: Context) -> CameraUIView { + let view = CameraUIView() + + let captureSession = AVCaptureSession() + guard let captureDevice = AVCaptureDevice.default(for: .video) else { return view } + guard let input = try? AVCaptureDeviceInput(device: captureDevice) else { return view } + captureSession.addInput(input) + + let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + view.previewLayer = previewLayer + + DispatchQueue.global(qos: .background).async { + captureSession.startRunning() + } + + return view + } + + func updateUIView(_ uiView: CameraUIView, context _: Context) { + uiView.previewLayer?.frame = uiView.bounds + } +} diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/GalleryView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/GalleryView.swift new file mode 100644 index 0000000..c3fa23c --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerComponents/GalleryView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct GalleryView: View { + @EnvironmentObject var attachments: AttachmentsStore + @Binding var selectedItems: [String] + + var body: some View { + Group { + if attachments.galleryAccessGranted { + ForEach(attachments.galleryItems) { item in + GridViewItem(item: item, selected: $selectedItems) + } + } else { + Button { + openAppSettings() + } label: { + ZStack { + Rectangle() + .fill(Color.Material.Background.light) + .overlay { + VStack { + Image(systemName: "photo") + .foregroundColor(.Material.Elements.active) + .font(.system(size: 30)) + Text("Allow gallery access") + .foregroundColor(.Material.Text.main) + .font(.body3) + } + } + .frame(height: 100) + } + } + } + } + .task { + await attachments.checkGalleryAuthorization() + } + } +} + +private struct GridViewItem: View { + @State var item: GalleryItem + @Binding var selected: [String] + + var body: some View { + if let img = item.thumbnail { + ZStack { + img + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) + .clipped() + if let duration = item.duration { + VStack { + Spacer() + HStack { + Spacer() + Text(duration) + .foregroundColor(.Material.Text.white) + .font(.sub1) + .shadow(color: .black, radius: 2) + .padding(4) + } + } + } + if isSelected { + VStack { + HStack { + Spacer() + Circle() + .frame(width: 30, height: 30) + .shadow(color: .black, radius: 2) + .foregroundColor(.Material.Shape.white) + .overlay { + Image(systemName: "checkmark") + .foregroundColor(.Material.Elements.active) + .font(.body3) + } + .padding(4) + } + Spacer() + } + } + } + .onTapGesture { + if isSelected { + selected.removeAll { $0 == item.id } + } else { + selected.append(item.id) + } + } + } else { + ZStack { + Rectangle() + .fill(Color.Material.Background.light) + .overlay { + ProgressView() + .foregroundColor(.Material.Elements.active) + } + .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) + } + .task { + if item.thumbnail == nil { + try? await item.fetchThumbnail() + } + } + } + } + + private var isSelected: Bool { + selected.contains(item.id) + } +} diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerView.swift new file mode 100644 index 0000000..24458e1 --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPickerView.swift @@ -0,0 +1,52 @@ +import AVFoundation +import MobileCoreServices +import Photos +import SwiftUI + +struct MediaPickerView: View { + @Environment(\.router) var router + @EnvironmentObject var attachments: AttachmentsStore + + @State private var selectedItems: [String] = [] + + var body: some View { + let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3) + + VStack(spacing: 0) { + // List of media + ScrollView(showsIndicators: false) { + LazyVGrid(columns: columns, spacing: 0) { + // For camera + CameraCellPreview() + + // For gallery + GalleryView(selectedItems: $selectedItems) + } + } + + // Send panel + Rectangle() + .foregroundColor(.Material.Shape.black) + .frame(maxWidth: .infinity) + .frame(height: self.selectedItems.isEmpty ? 0 : 50) + .overlay { + HStack { + Text(L10n.Attachment.Send.media) + .foregroundColor(.Material.Text.white) + .font(.body1) + Image(systemName: "arrow.up.circle") + .foregroundColor(.Material.Text.white) + .font(.body1) + .padding(.leading, 8) + } + .padding() + } + .clipped() + .onTapGesture { + let items = attachments.galleryItems.filter { selectedItems.contains($0.id) } + attachments.sendMedia(items) + router.dismissEnvironment() + } + } + } +} diff --git a/ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift b/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift similarity index 85% rename from ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift rename to ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift index 36cae1f..ffb7915 100644 --- a/ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift @@ -12,8 +12,8 @@ struct ConversationMessageContainer: View { EmbededMapView(location: msgText.getLatLon) } else if let msgText = message.body, msgText.isContact { ContactView(message: message) - } else if message.attachmentType != nil { - AttachmentView(message: message) + } else if case .attachment(let attachment) = message.contentType { + AttachmentView(message: message, attachment: attachment) } else { Text(message.body ?? "...") .font(.body2) @@ -33,11 +33,11 @@ struct MessageAttr: View { .font(.sub2) .foregroundColor(.Material.Shape.separator) Spacer() - if message.sentError { + if message.status == .error { Image(systemName: "exclamationmark.circle") .font(.body3) .foregroundColor(.Rainbow.red500) - } else if message.pending { + } else if message.status == .pending { Image(systemName: "clock") .font(.body3) .foregroundColor(.Material.Shape.separator) @@ -99,25 +99,29 @@ private struct ContactView: View { } private struct AttachmentView: View { + @EnvironmentObject var attachments: AttachmentsStore + let message: Message + let attachment: Attachment var body: some View { - if message.attachmentDownloadFailed || (message.attachmentLocalName != nil && message.sentError) { + if message.status == .error { failed } else { - switch message.attachmentType { + switch attachment.type { case .image: - if let thumbnail = thumbnail() { - thumbnail + AsyncImage(url: attachment.thumbnailPath) { image in + image .resizable() .aspectRatio(contentMode: .fit) .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) - } else { + } placeholder: { placeholder } + .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) - case .movie: - if let file = message.attachmentLocalPath { + case .video: + if let file = attachment.localPath { VideoPlayerView(url: file) .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) } else { @@ -125,7 +129,7 @@ private struct AttachmentView: View { } case .file: - if let file = message.attachmentLocalPath { + if let file = attachment.localPath { DocumentPreview(url: file) .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) } else { @@ -147,7 +151,7 @@ private struct AttachmentView: View { ProgressView() .scaleEffect(1.5) .progressViewStyle(CircularProgressViewStyle(tint: .Material.Elements.active)) - let imageName = progressImageName(message.attachmentType ?? .file) + let imageName = progressImageName(attachment.type) Image(systemName: imageName) .font(.body1) .foregroundColor(.Material.Elements.active) @@ -172,29 +176,30 @@ private struct AttachmentView: View { } } .onTapGesture { - if let url = message.attachmentRemotePath { - store.dispatch(.fileAction(.downloadAttachmentFile(messageId: message.id, attachmentRemotePath: url))) - } else if message.attachmentLocalName != nil && message.sentError { - store.dispatch(.sharingAction(.retrySharing(messageId: message.id))) + Task { + try? await message.setStatus(.pending) } } } - private func progressImageName(_ type: MessageAttachmentType) -> String { + private func progressImageName(_ type: AttachmentType) -> String { switch type { case .image: return "photo" + case .audio: return "music.note" - case .movie: + + case .video: return "film" + case .file: return "doc" } } private func thumbnail() -> Image? { - guard let thumbnailPath = message.attachmentThumbnailPath else { return nil } + guard let thumbnailPath = attachment.thumbnailPath else { return nil } guard let uiImage = UIImage(contentsOfFile: thumbnailPath.path()) else { return nil } return Image(uiImage: uiImage) } diff --git a/ConversationsClassic/View/Screens/Conversation/ConversationMessageRow.swift b/ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift similarity index 93% rename from ConversationsClassic/View/Screens/Conversation/ConversationMessageRow.swift rename to ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift index b3fa2df..57c1586 100644 --- a/ConversationsClassic/View/Screens/Conversation/ConversationMessageRow.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationMessageRow.swift @@ -2,8 +2,7 @@ import Foundation import SwiftUI struct ConversationMessageRow: View { - @EnvironmentObject var store: AppStore - + @EnvironmentObject var messages: MessagesStore let message: Message @State private var offset: CGSize = .zero @@ -51,7 +50,7 @@ struct ConversationMessageRow: View { } if value.translation.width <= targetWidth { DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { - store.dispatch(.conversationAction(.setReplyText(message.body ?? ""))) + messages.replyText = message.body ?? "" } } } @@ -64,7 +63,7 @@ struct ConversationMessageRow: View { } private func isOutgoing() -> Bool { - message.from == store.state.conversationsState.currentChat?.account + message.from == messages.roster.bareJid } } diff --git a/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift b/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift new file mode 100644 index 0000000..6c5a021 --- /dev/null +++ b/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift @@ -0,0 +1,109 @@ +import Combine +import Foundation +import Martin +import SwiftUI + +struct ConversationScreen: View { + @Environment(\.router) var router + @StateObject var messagesStore: MessagesStore + @StateObject var attachments: AttachmentsStore + + @State private var autoScroll = true + @State private var firstIsVisible = true + + var body: some View { + ZStack { + // Background color + Color.Material.Background.light + .ignoresSafeArea() + + // Content + VStack(spacing: 0) { + // Header + SharedNavigationBar( + leftButton: .init( + image: Image(systemName: "chevron.left"), + action: { + router.dismissScreen() + } + ), + centerText: .init(text: L10n.Conversation.title) + ) + + // Msg list + let messages = messagesStore.messages + if !messages.isEmpty { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(messages) { message in + ConversationMessageRow(message: message) + .id(message.id) + .flip() + .onAppear { + if message.id == messages.first?.id { + firstIsVisible = true + autoScroll = true + } + messagesStore.scrolledMessage(message.id) + } + .onDisappear { + if message.id == messages.first?.id { + firstIsVisible = false + autoScroll = false + } + } + } + } + } + .flip() + .scrollDismissesKeyboard(.immediately) + .onChange(of: autoScroll) { new in + if new, !firstIsVisible { + withAnimation { + proxy.scrollTo(messages.first?.id, anchor: .top) + } + } + } + } + } else { + Spacer() + } + } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + // Jump to last button + if !autoScroll { + VStack { + Spacer() + HStack { + Spacer() + Button { + autoScroll = true + } label: { + ZStack { + Circle() + .fill(Color.Material.Shape.white) + Image(systemName: "arrow.down") + .foregroundColor(.Material.Elements.active) + } + .frame(width: 40, height: 40) + .shadow(color: .black.opacity(0.2), radius: 4) + .padding(.trailing, 8) + .padding(.bottom, 8) + } + } + } + } + } + .environmentObject(messagesStore) + .environmentObject(attachments) + .safeAreaInset(edge: .bottom, spacing: 0) { + ConversationTextInput(autoScroll: $autoScroll) + .environmentObject(messagesStore) + .environmentObject(attachments) + } + } +} diff --git a/ConversationsClassic/View/Screens/Conversation/ConversationTextInput.swift b/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift similarity index 69% rename from ConversationsClassic/View/Screens/Conversation/ConversationTextInput.swift rename to ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift index a0b089d..681ff27 100644 --- a/ConversationsClassic/View/Screens/Conversation/ConversationTextInput.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift @@ -2,7 +2,9 @@ import SwiftUI import UIKit struct ConversationTextInput: View { - @EnvironmentObject var store: AppStore + @Environment(\.router) var router + @EnvironmentObject var messages: MessagesStore + @EnvironmentObject var attachments: AttachmentsStore @State private var messageStr = "" @FocusState private var isFocused: Bool @@ -14,10 +16,10 @@ struct ConversationTextInput: View { .foregroundColor(.Material.Shape.separator) .frame(height: 0.5) .padding(.bottom, 8) - if !replyText.isEmpty { + if !messages.replyText.isEmpty { VStack(spacing: 0) { HStack(alignment: .top) { - Text(replyText) + Text(messages.replyText) .font(.body3) .foregroundColor(Color.Material.Text.main) .multilineTextAlignment(.leading) @@ -29,7 +31,7 @@ struct ConversationTextInput: View { .foregroundColor(.Material.Elements.active) .padding(.leading, 8) .tappablePadding(.symmetric(8)) { - store.dispatch(.conversationAction(.setReplyText(""))) + messages.replyText = "" } .padding(8) } @@ -49,9 +51,13 @@ struct ConversationTextInput: View { .foregroundColor(.Material.Elements.active) .padding(.leading, 8) .tappablePadding(.symmetric(8)) { - store.dispatch(.sharingAction(.showSharing(true))) + router.showScreen(.fullScreenCover) { _ in + AttachmentPickerScreen() + .environmentObject(messages) + .environmentObject(attachments) + } } - TextField("", text: $messageStr, prompt: Text(L10n.Chat.textfieldPrompt).foregroundColor(.Material.Shape.separator)) + TextField("", text: $messageStr, prompt: Text(L10n.Chat.textfieldPrompt).foregroundColor(.Material.Shape.separator), axis: .vertical) .font(.body1) .foregroundColor(Color.Material.Text.main) .accentColor(.Material.Shape.black) @@ -68,44 +74,29 @@ struct ConversationTextInput: View { .padding(.trailing, 8) .tappablePadding(.symmetric(8)) { if !messageStr.isEmpty { - guard let acc = store.state.conversationsState.currentChat?.account else { return } - guard let contact = store.state.conversationsState.currentChat?.participant else { return } - store.dispatch(.conversationAction(.sendMessage( - from: acc, - to: contact, - body: composedMessage - ))) + messages.sendMessage(composedMessage) messageStr = "" - // UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - store.dispatch(.conversationAction(.setReplyText(""))) autoScroll = true + if !messages.replyText.isEmpty { + messages.replyText = "" + } } } } } .padding(.bottom, 8) .background(Color.Material.Background.dark) - .onChange(of: store.state.conversationsState.replyText) { new in + .onChange(of: messages.replyText) { new in if !new.isEmpty { isFocused = true } } - .fullScreenCover(isPresented: Binding( - get: { store.state.sharingState.sharingShown }, - set: { _ in } - )) { - AttachmentPickerScreen() - } - } - - private var replyText: String { - store.state.conversationsState.replyText } private var composedMessage: String { var result = "" - if !replyText.isEmpty { - result += replyText + "\n\n" + if !messages.replyText.isEmpty { + result += messages.replyText.makeReply + "\n\n" } result += messageStr return result diff --git a/ConversationsClassic/View/Main/MainTabScreen.swift b/ConversationsClassic/View/Main/MainTabScreen.swift new file mode 100644 index 0000000..e34311f --- /dev/null +++ b/ConversationsClassic/View/Main/MainTabScreen.swift @@ -0,0 +1,111 @@ +import Foundation +import SwiftfulRouting +import SwiftUI + +private enum Tab { + case chats + case contacts + case settings +} + +struct MainTabScreen: View { + @EnvironmentObject var clientsStore: ClientsStore + + @State private var selectedTab: Tab = .chats + + var body: some View { + ZStack { + // Background color + Color.Material.Background.light + .ignoresSafeArea() + + // Content + VStack(spacing: 0) { + switch selectedTab { + case .chats: + ChatsListScreen() + + case .contacts: + ContactsScreen() + + case .settings: + SettingsScreen() + } + + // Tab bar + TabBar(selectedTab: $selectedTab) + } + } + } +} + +private struct TabBar: View { + @Binding var selectedTab: Tab + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .frame(maxWidth: .infinity) + .frame(height: 0.2) + .foregroundColor(.Material.Shape.separator) + HStack(spacing: 0) { + TabBarButton(buttonType: .contacts, selectedTab: $selectedTab) + TabBarButton(buttonType: .chats, selectedTab: $selectedTab) + TabBarButton(buttonType: .settings, selectedTab: $selectedTab) + } + .background(Color.Material.Background.dark) + } + .frame(height: 50) + } +} + +private struct TabBarButton: View { + let buttonType: Tab + + @Binding var selectedTab: Tab + + var body: some View { + ZStack { + VStack(spacing: 2) { + buttonImg + .foregroundColor(buttonType == selectedTab ? .Material.Elements.active : .Material.Elements.inactive) + .font(.system(size: 24, weight: .light)) + .symbolRenderingMode(.hierarchical) + Text(buttonTitle) + .font(.sub1) + .foregroundColor(buttonType == selectedTab ? .Material.Text.main : .Material.Elements.inactive) + } + Rectangle() + .foregroundColor(.white.opacity(0.01)) + .onTapGesture { + selectedTab = buttonType + } + } + } + + var buttonImg: Image { + switch buttonType { + case .contacts: + return Image(systemName: "person.2.fill") + + case .chats: + return Image(systemName: "bubble.left.fill") + + case .settings: + return Image(systemName: "gearshape.fill") + } + } + + var buttonTitle: String { + switch buttonType { + case .contacts: + return L10n.Tabs.Name.contacts + + case .chats: + return L10n.Tabs.Name.conversations + + case .settings: + return L10n.Tabs.Name.settings + } + } +} diff --git a/ConversationsClassic/View/Screens/Settings/SettingsScreen.swift b/ConversationsClassic/View/Main/Settings/SettingsScreen.swift similarity index 59% rename from ConversationsClassic/View/Screens/Settings/SettingsScreen.swift rename to ConversationsClassic/View/Main/Settings/SettingsScreen.swift index 98ee46c..99c77da 100644 --- a/ConversationsClassic/View/Screens/Settings/SettingsScreen.swift +++ b/ConversationsClassic/View/Main/Settings/SettingsScreen.swift @@ -8,13 +8,9 @@ struct SettingsScreen: View { .ignoresSafeArea() // content - Text("under construction...") - - // tab bar - VStack { - Spacer() - SharedTabBar() - } + Text("Soon!") + .font(.head1l) + .foregroundColor(.Material.Elements.active) } } } diff --git a/ConversationsClassic/View/RootView.swift b/ConversationsClassic/View/RootView.swift new file mode 100644 index 0000000..61b3638 --- /dev/null +++ b/ConversationsClassic/View/RootView.swift @@ -0,0 +1,24 @@ +import SwiftfulRouting +import SwiftUI + +struct RootView: View { + @EnvironmentObject var clientsStore: ClientsStore + + var body: some View { + Group { + if clientsStore.ready { + if clientsStore.clients.isEmpty { + RouterView { _ in + WelcomeScreen() + } + } else { + RouterView { _ in + MainTabScreen() + } + } + } else { + StartScreen() + } + } + } +} diff --git a/ConversationsClassic/View/Screens/Chats/ChatsListScreen.swift b/ConversationsClassic/View/Screens/Chats/ChatsListScreen.swift deleted file mode 100644 index 572c23c..0000000 --- a/ConversationsClassic/View/Screens/Chats/ChatsListScreen.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI - -struct ChatsListScreen: View { - @EnvironmentObject var store: AppStore - - @State private var isCretePanelPresented = false - - var body: some View { - ZStack { - // Background color - Color.Material.Background.light - .ignoresSafeArea() - - // Content - VStack(spacing: 0) { - // Header - SharedNavigationBar( - centerText: .init(text: L10n.Chats.title), - rightButton: .init( - image: Image(systemName: "square.and.pencil"), - action: { - isCretePanelPresented = true - } - ) - ) - - // Chats list - if !store.state.chatsState.chats.isEmpty { - List { - ForEach(store.state.chatsState.chats) { chat in - ChatsRow(chat: chat) - } - } - .listStyle(.plain) - .background(Color.Material.Background.light) - } else { - Spacer() - } - - // Tab bar - SharedTabBar() - } - } - .fullScreenCover(isPresented: $isCretePanelPresented) { - ChatsCreateMainScreen(isPresented: $isCretePanelPresented) - } - } -} - -private struct ChatsRow: View { - @EnvironmentObject var store: AppStore - - var chat: Chat - - var body: some View { - SharedListRow(iconType: .charCircle(chat.participant), text: chat.participant) - .onTapGesture { - store.dispatch(.chatsAction(.startChat(accountJid: chat.account, participantJid: chat.participant))) - } - } -} diff --git a/ConversationsClassic/View/Screens/Contacts/ContactsScreen.swift b/ConversationsClassic/View/Screens/Contacts/ContactsScreen.swift deleted file mode 100644 index d47c736..0000000 --- a/ConversationsClassic/View/Screens/Contacts/ContactsScreen.swift +++ /dev/null @@ -1,172 +0,0 @@ -import SwiftUI - -struct ContactsScreen: View { - @EnvironmentObject var store: AppStore - - @State private var addPanelPresented = false - @State private var isErrorAlertPresented = false - @State private var errorAlertMessage = "" - @State private var isShowingLoader = false - - var body: some View { - ZStack { - // Background color - Color.Material.Background.light - .ignoresSafeArea() - - // Content - VStack(spacing: 0) { - // Header - SharedNavigationBar( - centerText: .init(text: L10n.Contacts.title), - rightButton: .init( - image: Image(systemName: "plus"), - action: { - addPanelPresented = true - } - ) - ) - - // Contacts list - let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted } - if !rosters.isEmpty { - List { - ForEach(rosters) { roster in - ContactsScreenRow( - roster: roster, - isErrorAlertPresented: $isErrorAlertPresented, - errorAlertMessage: $errorAlertMessage, - isShowingLoader: $isShowingLoader - ) - } - } - .listStyle(.plain) - .background(Color.Material.Background.light) - } else { - Spacer() - } - - // Tab bar - SharedTabBar() - } - } - .loadingIndicator(isShowingLoader) - .fullScreenCover(isPresented: $addPanelPresented) { - AddContactOrChannelScreen(isPresented: $addPanelPresented) - } - .alert(isPresented: $isErrorAlertPresented) { - Alert( - title: Text(L10n.Global.Error.title), - message: Text(errorAlertMessage), - dismissButton: .default(Text(L10n.Global.ok)) - ) - } - } -} - -private struct ContactsScreenRow: View { - @EnvironmentObject var store: AppStore - - var roster: Roster - @State private var isShowingMenu = false - @State private var isDeleteAlertPresented = false - - @Binding var isErrorAlertPresented: Bool - @Binding var errorAlertMessage: String - @Binding var isShowingLoader: Bool - - var body: some View { - SharedListRow( - iconType: .charCircle(roster.name?.firstLetter ?? roster.contactBareJid.firstLetter), - text: roster.contactBareJid - ) - // VStack(spacing: 0) { - // HStack(spacing: 8) { - // ZStack { - // Circle() - // .frame(width: 44, height: 44) - // .foregroundColor(.red) - // Text(roster.name?.firstLetter ?? roster.contactBareJid.firstLetter) - // .foregroundColor(.white) - // .font(.body1) - // } - // Text(roster.contactBareJid) - // .foregroundColor(Color.Material.Text.main) - // .font(.body2) - // Spacer() - // } - // .padding(.horizontal, 16) - // .padding(.vertical, 4) - // Rectangle() - // .frame(maxWidth: .infinity) - // .frame(height: 1) - // .foregroundColor(.Material.Background.dark) - // } - // .sharedListRow() - .onTapGesture { - store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid))) - } - .onLongPressGesture { - isShowingMenu.toggle() - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - isDeleteAlertPresented = true - } label: { - Label(L10n.Contacts.sendMessage, systemImage: "trash") - } - .tint(Color.red) - } - .contextMenu { - Button(L10n.Contacts.sendMessage, systemImage: "message") { - store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid))) - } - Divider() - - Button(L10n.Contacts.editContact) { - print("Edit contact") - } - - Button(L10n.Contacts.selectContact) { - print("Select contact") - } - - Divider() - Button(L10n.Contacts.deleteContact, systemImage: "trash", role: .destructive) { - isDeleteAlertPresented = true - } - } - .actionSheet(isPresented: $isDeleteAlertPresented) { - ActionSheet( - title: Text(L10n.Contacts.Delete.title), - message: Text(L10n.Contacts.Delete.message), - buttons: [ - .destructive(Text(L10n.Contacts.Delete.deleteFromDevice)) { - store.dispatch(.rostersAction(.markRosterAsLocallyDeleted(ownerJID: roster.bareJid, contactJID: roster.contactBareJid))) - }, - .destructive(Text(L10n.Contacts.Delete.deleteCompletely)) { - isShowingLoader = true - store.dispatch(.rostersAction(.deleteRoster(ownerJID: roster.bareJid, contactJID: roster.contactBareJid))) - }, - .cancel(Text(L10n.Global.cancel)) - ] - ) - } - .onChange(of: store.state.rostersState.rosters) { _ in - endOfDeleting() - } - .onChange(of: store.state.rostersState.deleteRosterError) { _ in - endOfDeleting() - } - } - - private func endOfDeleting() { - if isShowingLoader { - isShowingLoader = false - if let error = store.state.rostersState.deleteRosterError { - errorAlertMessage = error - isErrorAlertPresented = true - } - } - } -} diff --git a/ConversationsClassic/View/Screens/Conversation/ConversationScreen.swift b/ConversationsClassic/View/Screens/Conversation/ConversationScreen.swift deleted file mode 100644 index 73657bf..0000000 --- a/ConversationsClassic/View/Screens/Conversation/ConversationScreen.swift +++ /dev/null @@ -1,198 +0,0 @@ -import Combine -import Foundation -import Martin -import SwiftUI - -struct ConversationScreen: View { - @EnvironmentObject var store: AppStore - - @State private var autoScroll = true - @State private var firstIsVisible = true - - var body: some View { - ZStack { - // Background color - Color.Material.Background.light - .ignoresSafeArea() - - // Content - VStack(spacing: 0) { - // Header - let name = ( - store.state.conversationsState.currentRoster?.name ?? - store.state.conversationsState.currentRoster?.contactBareJid - ) ?? L10n.Chat.title - SharedNavigationBar( - leftButton: .init( - image: Image(systemName: "chevron.left"), - action: { - store.dispatch(.changeFlow(store.state.previousFlow)) - } - ), - centerText: .init(text: name) - ) - - // Msg list - let messages = store.state.conversationsState.currentMessages - if !messages.isEmpty { - ScrollViewReader { proxy in - List { - ForEach(messages) { message in - ConversationMessageRow(message: message) - .id(message.id) - .onAppear { - if message.id == messages.first?.id { - firstIsVisible = true - autoScroll = true - } - } - .onDisappear { - if message.id == messages.first?.id { - firstIsVisible = false - autoScroll = false - } - } - } - .rotationEffect(.degrees(180)) - } - .rotationEffect(.degrees(180)) - .listStyle(.plain) - .background(Color.Material.Background.light) - .scrollDismissesKeyboard(.immediately) - .scrollIndicators(.hidden) - .onChange(of: autoScroll) { new in - if new, !firstIsVisible { - withAnimation { - proxy.scrollTo(messages.first?.id, anchor: .top) - } - } - } - } - } else { - Spacer() - } - } - .onTapGesture { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } - - // Jump to last button - if !autoScroll { - VStack { - Spacer() - HStack { - Spacer() - Button { - autoScroll = true - } label: { - ZStack { - Circle() - .fill(Color.Material.Shape.white) - Image(systemName: "arrow.down") - .foregroundColor(.Material.Elements.active) - } - .frame(width: 40, height: 40) - .shadow(color: .black.opacity(0.2), radius: 4) - .padding(.trailing, 8) - .padding(.bottom, 8) - } - } - } - } - } - .safeAreaInset(edge: .bottom, spacing: 0) { - ConversationTextInput(autoScroll: $autoScroll) - } - } -} - -// Preview -#if DEBUG - struct ConversationScreen_Previews: PreviewProvider { - static var previews: some View { - ConversationScreen() - .environmentObject(pStore) - } - - static var pStore: AppStore { - let state = pState - return AppStore(initialState: state, reducer: AppState.reducer, middlewares: []) - } - - static var pState: AppState { - var state = AppState() - - let acc = "user@test.com" - let contact = "some@test.com" - - state.conversationsState.currentChat = Chat(id: "1", account: acc, participant: contact, type: .chat) - state.conversationsState.currentMessages = [ - Message( - id: "1", - type: .chat, - contentType: .text, - from: contact, - to: acc, - body: "this is for test sdgdsfg dsfg dsfgdg dsfgdfgsdgsdfgdfg sdfgdsfgdfsg dsfgdsfgsdfg dsfgdfgsdg fgf fgfg sdfsdf sdfsdf sdf sdfsdf sdf sdfsdf sdfsdfsdf sdfsdf ", - subject: nil, - thread: nil, - oobUrl: nil, - date: Date(), - pending: true, sentError: false - ), - Message( - id: "2", - type: .chat, - contentType: .text, - from: contact, - to: acc, - body: "this is for testsdfsdf sdfsdf sdfs sdf sdffsdf sdf sdf sdf sdf sdf sdff sdfffwwe ", - subject: nil, - thread: nil, - oobUrl: nil, - date: Date(), - pending: false, - sentError: false - ), - Message(id: "3", type: .chat, contentType: .text, from: contact, to: acc, body: "this is for test", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: true), - Message( - id: "4", - type: .chat, - contentType: .text, - from: acc, - to: contact, - body: "this is for test sdfkjwek jwkjfh jwerf jdfhskjdhf jsdhfjhwefh sjdhfh fsdjhfh sd ", - subject: nil, - thread: nil, - oobUrl: nil, - date: Date(), - pending: false, - sentError: false - ), - Message(id: "5", type: .chat, contentType: .text, from: contact, to: acc, body: "this is for test sdfjkkeke kekkddjw;; w;edkdjfj l kjwekrjfk wef", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: false), - Message(id: "6", type: .chat, contentType: .text, from: acc, to: contact, body: "this is for testsdf dsdkkekkddn wejkjfj ", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: false), - Message( - id: "7", - type: .chat, - contentType: .text, - from: acc, - to: contact, - - body: "this is for test sdgdsfg dsfg dsfgdg dsfgdfgsdgsdfgdfg sdfgdsfgdfsg dsfgdsfgsdfg dsfgdfgsdg fgf fgfg sdfsdf sdfsdf sdf sdfsdf sdf sdfsdf sdfsdfsdf sdfsdf ", - subject: nil, - thread: nil, - oobUrl: nil, - date: Date(), pending: false, sentError: false - ), - Message(id: "8", type: .chat, contentType: .text, from: acc, to: contact, body: "so test", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: false), - Message(id: "9", type: .chat, contentType: .text, from: contact, to: acc, body: "so test", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: false), - Message(id: "10", type: .chat, contentType: .text, from: acc, to: contact, body: "so test so test so test", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: false), - Message(id: "11", type: .chat, contentType: .text, from: contact, to: acc, body: "xD", subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: false, sentError: false) - ] - - state.conversationsState.replyText = "> Some Text here! And if it a long and very long text sdfsadfsadfsafsadfsadfsadfsadfassadfsadfsafsafdsadfsafdsadfsadfas sdf sdf asdf sdfasdfsd sdfasdf sdfsdfdsasdfsdfa dsafsaf" - - return state - } - } -#endif diff --git a/ConversationsClassic/View/Screens/Sharing/SharingMediaPickerView.swift b/ConversationsClassic/View/Screens/Sharing/SharingMediaPickerView.swift deleted file mode 100644 index 17cbed0..0000000 --- a/ConversationsClassic/View/Screens/Sharing/SharingMediaPickerView.swift +++ /dev/null @@ -1,289 +0,0 @@ -import AVFoundation -import MobileCoreServices -import Photos -import SwiftUI - -struct SharingMediaPickerView: View { - @EnvironmentObject var store: AppStore - @State private var showCameraPicker = false - @State private var cameraReady = false - - @State private var selectedItems: [String] = [] - - var body: some View { - let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3) - - VStack(spacing: 0) { - // List of media - ScrollView(showsIndicators: false) { - LazyVGrid(columns: columns, spacing: 0) { - // For camera - if store.state.sharingState.isCameraAccessGranted { - if cameraReady { - ZStack { - CameraView() - .aspectRatio(1, contentMode: .fit) - .frame(maxWidth: .infinity) - Image(systemName: "camera") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40) - .foregroundColor(.white) - .padding(8) - .background(Color.black.opacity(0.5)) - .clipShape(Circle()) - .padding(8) - } - .onTapGesture { - showCameraPicker = true - } - } else { - ProgressView() - .frame(maxWidth: .infinity) - .frame(height: 100) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - cameraReady = true - } - } - } - } else { - Button { - openAppSettings() - } label: { - ZStack { - Rectangle() - .fill(Color.Material.Background.light) - .overlay { - VStack { - Image(systemName: "camera") - .foregroundColor(.Material.Elements.active) - .font(.system(size: 30)) - Text("Allow camera access") - .foregroundColor(.Material.Text.main) - .font(.body3) - } - } - .frame(height: 100) - } - } - } - - // For gallery - if store.state.sharingState.isGalleryAccessGranted { - ForEach(store.state.sharingState.galleryItems) { item in - GridViewItem(item: item, selected: $selectedItems) - } - } else { - Button { - openAppSettings() - } label: { - ZStack { - Rectangle() - .fill(Color.Material.Background.light) - .overlay { - VStack { - Image(systemName: "photo") - .foregroundColor(.Material.Elements.active) - .font(.system(size: 30)) - Text("Allow gallery access") - .foregroundColor(.Material.Text.main) - .font(.body3) - } - } - .frame(height: 100) - } - } - } - } - } - .fullScreenCover(isPresented: $showCameraPicker) { - CameraPicker(sourceType: .camera) { data, type in - store.dispatch(.sharingAction(.cameraCaptured(media: data, type: type))) - showCameraPicker = false - store.dispatch(.sharingAction(.showSharing(false))) - } - .edgesIgnoringSafeArea(.all) - } - - // Send panel - Rectangle() - .foregroundColor(.Material.Shape.black) - .frame(maxWidth: .infinity) - .frame(height: self.selectedItems.isEmpty ? 0 : 50) - .overlay { - HStack { - Text(L10n.Attachment.Send.media) - .foregroundColor(.Material.Text.white) - .font(.body1) - Image(systemName: "arrow.up.circle") - .foregroundColor(.Material.Text.white) - .font(.body1) - .padding(.leading, 8) - } - .padding() - } - .clipped() - .onTapGesture { - store.dispatch(.sharingAction(.shareMedia(ids: selectedItems))) - store.dispatch(.sharingAction(.showSharing(false))) - } - } - .onAppear { - store.dispatch(.sharingAction(.checkCameraAccess)) - store.dispatch(.sharingAction(.checkGalleryAccess)) - } - .onChange(of: store.state.sharingState.isGalleryAccessGranted) { granted in - if granted { - store.dispatch(.fileAction(.fetchItemsFromGallery)) - } - } - } -} - -private struct GridViewItem: View { - let item: SharingGalleryItem - @Binding var selected: [String] - @State var isSelected = false - - var body: some View { - if let data = item.thumbnail { - ZStack { - Image(uiImage: UIImage(data: data) ?? UIImage()) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) - .clipped() - if let duration = item.duration { - VStack { - Spacer() - HStack { - Spacer() - Text(duration) - .foregroundColor(.Material.Text.white) - .font(.sub1) - .shadow(color: .black, radius: 2) - .padding(4) - } - } - } - if isSelected { - VStack { - HStack { - Spacer() - Circle() - .frame(width: 30, height: 30) - .shadow(color: .black, radius: 2) - .foregroundColor(.Material.Shape.white) - .overlay { - Image(systemName: "checkmark") - .foregroundColor(.Material.Elements.active) - .font(.body3) - } - .padding(4) - } - Spacer() - } - } - } - .onTapGesture { - isSelected.toggle() - if isSelected { - selected.append(item.id) - } else { - selected.removeAll { $0 == item.id } - } - } - } else { - ZStack { - Rectangle() - .fill(Color.Material.Background.light) - .overlay { - ProgressView() - .foregroundColor(.Material.Elements.active) - } - .frame(width: Const.galleryGridSize, height: Const.galleryGridSize) - } - } - } -} - -class CameraUIView: UIView { - var previewLayer: AVCaptureVideoPreviewLayer? - - override func layoutSubviews() { - super.layoutSubviews() - previewLayer?.frame = bounds - } -} - -struct CameraView: UIViewRepresentable { - func makeUIView(context _: Context) -> CameraUIView { - let view = CameraUIView() - - let captureSession = AVCaptureSession() - guard let captureDevice = AVCaptureDevice.default(for: .video) else { return view } - guard let input = try? AVCaptureDeviceInput(device: captureDevice) else { return view } - captureSession.addInput(input) - - let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - previewLayer.videoGravity = .resizeAspectFill - view.layer.addSublayer(previewLayer) - view.previewLayer = previewLayer - - captureSession.startRunning() - - return view - } - - func updateUIView(_ uiView: CameraUIView, context _: Context) { - uiView.previewLayer?.frame = uiView.bounds - } -} - -struct CameraPicker: UIViewControllerRepresentable { - var sourceType: UIImagePickerController.SourceType - var completionHandler: (Data, SharingCameraMediaType) -> Void - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = sourceType - picker.delegate = context.coordinator - picker.mediaTypes = [UTType.movie.identifier, UTType.image.identifier] - picker.videoQuality = .typeHigh - picker.videoMaximumDuration = Const.videoDurationLimit - picker.view.backgroundColor = .clear - return picker - } - - func updateUIViewController(_: UIImagePickerController, context _: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: CameraPicker - - init(_ parent: CameraPicker) { - self.parent = parent - } - - func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - // swiftlint:disable:next force_cast - let mediaType = info[.mediaType] as! String - - if mediaType == UTType.image.identifier { - if let image = info[.originalImage] as? UIImage { - let data = image.jpegData(compressionQuality: 1.0) ?? Data() - parent.completionHandler(data, .photo) - } - } else if mediaType == UTType.movie.identifier { - if let url = info[.mediaURL] as? URL { - let data = try? Data(contentsOf: url) - parent.completionHandler(data ?? Data(), .video) - } - } - } - } -} diff --git a/ConversationsClassic/View/Screens/Sharing/SharingPickerScreen.swift b/ConversationsClassic/View/Screens/Sharing/SharingPickerScreen.swift deleted file mode 100644 index d67e9ca..0000000 --- a/ConversationsClassic/View/Screens/Sharing/SharingPickerScreen.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -struct AttachmentPickerScreen: View { - @EnvironmentObject var store: AppStore - - @State private var selectedTab: SharingTab = .media - - var body: some View { - ZStack { - // Background color - Color.Material.Background.light - .ignoresSafeArea() - - // Content - VStack(spacing: 0) { - // Header - SharedNavigationBar( - centerText: .init(text: L10n.Attachment.Prompt.main), - rightButton: .init( - image: Image(systemName: "xmark"), - action: { - store.dispatch(.sharingAction(.showSharing(false))) - } - ) - ) - - // Pickers - switch selectedTab { - case .media: - SharingMediaPickerView() - - case .files: - SharingFilesPickerView() - - case .location: - SharingLocationPickerView() - - case .contacts: - SharingContactsPickerView() - } - - // Tab bar - SharingTabBar(selectedTab: $selectedTab) - } - } - } -} diff --git a/ConversationsClassic/View/SharedComponents/LoadingScreen.swift b/ConversationsClassic/View/SharedComponents/LoadingScreen.swift new file mode 100644 index 0000000..154987a --- /dev/null +++ b/ConversationsClassic/View/SharedComponents/LoadingScreen.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct LoadingScreen: View { + var body: some View { + GeometryReader { geo in + ZStack { + // background with opacity + Color.Material.Elements.active.opacity(0.3) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // loader + ProgressView() + .progressViewStyle( + CircularProgressViewStyle(tint: .Material.Elements.active) + ) + .position(x: geo.size.width / 2, y: geo.size.height / 2) + .controlSize(.large) + } + } + .ignoresSafeArea() + .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.1))) + } +} diff --git a/ConversationsClassic/View/SharedComponents/SharedTabBar.swift b/ConversationsClassic/View/SharedComponents/SharedTabBar.swift deleted file mode 100644 index 8ca982c..0000000 --- a/ConversationsClassic/View/SharedComponents/SharedTabBar.swift +++ /dev/null @@ -1,76 +0,0 @@ -import SwiftUI - -struct SharedTabBar: View { - var body: some View { - VStack(spacing: 0) { - Rectangle() - .frame(maxWidth: .infinity) - .frame(height: 0.2) - .foregroundColor(.Material.Shape.separator) - HStack(spacing: 0) { - SharedTabBarButton(buttonFlow: .contacts) - SharedTabBarButton(buttonFlow: .chats) - SharedTabBarButton(buttonFlow: .settings) - } - .background(Color.Material.Background.dark) - } - .frame(height: 50) - } -} - -private struct SharedTabBarButton: View { - @EnvironmentObject var store: AppStore - - let buttonFlow: AppFlow - - var body: some View { - ZStack { - VStack(spacing: 2) { - buttonImg - .foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Elements.active : .Material.Elements.inactive) - .font(.system(size: 24, weight: .light)) - .symbolRenderingMode(.hierarchical) - Text(buttonTitle) - .font(.sub1) - .foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Text.main : .Material.Elements.inactive) - } - Rectangle() - .foregroundColor(.white.opacity(0.01)) - .onTapGesture { - store.dispatch(.changeFlow(buttonFlow)) - } - } - } - - var buttonImg: Image { - switch buttonFlow { - case .contacts: - return Image(systemName: "person.2.fill") - - case .chats: - return Image(systemName: "bubble.left.fill") - - case .settings: - return Image(systemName: "gearshape.fill") - - default: - return Image(systemName: "questionmark.circle") - } - } - - var buttonTitle: String { - switch buttonFlow { - case .contacts: - return "Contacts" - - case .chats: - return "Chats" - - case .settings: - return "Settings" - - default: - return "Unknown" - } - } -} diff --git a/ConversationsClassic/View/Screens/StartScreen.swift b/ConversationsClassic/View/StartScreen.swift similarity index 70% rename from ConversationsClassic/View/Screens/StartScreen.swift rename to ConversationsClassic/View/StartScreen.swift index ee7a501..71564bd 100644 --- a/ConversationsClassic/View/Screens/StartScreen.swift +++ b/ConversationsClassic/View/StartScreen.swift @@ -1,7 +1,7 @@ import SwiftUI struct StartScreen: View { - @EnvironmentObject var store: AppStore + @EnvironmentObject var clientsStore: ClientsStore var body: some View { ZStack { @@ -12,8 +12,5 @@ struct StartScreen: View { .frame(width: 200, height: 200) } .ignoresSafeArea() - .onAppear { - store.dispatch(.startAction(.loadStoredAccounts)) - } } } diff --git a/ConversationsClassic/View/UIToolkit/KeyboardDisposableModifier.swift b/ConversationsClassic/View/UIToolkit/KeyboardDisposableModifier.swift deleted file mode 100644 index 8b372fd..0000000 --- a/ConversationsClassic/View/UIToolkit/KeyboardDisposableModifier.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -private struct ContentBlockModifier: ViewModifier { - var focus: FocusState.Binding - - func body(content: Content) -> some View { - content - .background { - Rectangle() - .foregroundColor(.white.opacity(0.01)) - .onTapGesture { - focus.wrappedValue = nil - } - } - } -} - -extension View { - func keyboardUnfocus(_ focus: FocusState.Binding) -> some View { - self.modifier(ContentBlockModifier(focus: focus)) - } -} diff --git a/ConversationsClassic/View/UIToolkit/UIImage+Crop.swift b/ConversationsClassic/View/UIToolkit/UIImage+Crop.swift deleted file mode 100644 index 1bccefd..0000000 --- a/ConversationsClassic/View/UIToolkit/UIImage+Crop.swift +++ /dev/null @@ -1,42 +0,0 @@ -import UIKit - -extension UIImage { - func scaleAndCropImage(toExampleSize _: CGSize, completion: @escaping (UIImage?) -> Void) { - DispatchQueue.global(qos: .background).async { - guard let cgImage = self.cgImage else { - DispatchQueue.main.async { - completion(nil) - } - return - } - let contextImage: UIImage = .init(cgImage: cgImage) - var contextSize: CGSize = contextImage.size - - var posX: CGFloat = 0.0 - var posY: CGFloat = 0.0 - let cgwidth: CGFloat = self.size.width - let cgheight: CGFloat = self.size.height - - // Check and handle if the image is wider than the requested size - if contextSize.width > contextSize.height { - posX = ((contextSize.width - contextSize.height) / 2) - contextSize.width = contextSize.height - } else if contextSize.width < contextSize.height { - // Check and handle if the image is taller than the requested size - posY = ((contextSize.height - contextSize.width) / 2) - contextSize.height = contextSize.width - } - - let rect: CGRect = .init(x: posX, y: posY, width: cgwidth, height: cgheight) - guard let contextCg = contextImage.cgImage, let imgRef = contextCg.cropping(to: rect) else { - DispatchQueue.main.async { - completion(nil) - } - return - } - let image: UIImage = .init(cgImage: imgRef, scale: self.scale, orientation: self.imageOrientation) - - completion(image) - } - } -} diff --git a/ConversationsClassic/View/UIToolkit/View+Loader.swift b/ConversationsClassic/View/UIToolkit/View+Loader.swift deleted file mode 100644 index 3d0a76c..0000000 --- a/ConversationsClassic/View/UIToolkit/View+Loader.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import SwiftUI - -public extension View { - func loadingIndicator(_ isShowing: Bool) -> some View { - modifier(LoadingIndicator(isShowing: isShowing)) - } -} - -struct LoadingIndicator: ViewModifier { - var isShowing: Bool - - func body(content: Content) -> some View { - ZStack { - content - if isShowing { - loadingView - } - } - } - - private var loadingView: some View { - GeometryReader { proxyReader in - ZStack { - Color.Material.Elements.active.opacity(0.3) - .frame(maxWidth: .infinity, maxHeight: .infinity) - - // loader - ProgressView() - .progressViewStyle( - CircularProgressViewStyle(tint: .Material.Elements.active) - ) - .position(x: proxyReader.size.width / 2, y: proxyReader.size.height / 2) - .controlSize(.large) - } - } - .ignoresSafeArea() - .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.1))) - } -} diff --git a/project.yml b/project.yml index ffa2075..f90cf15 100644 --- a/project.yml +++ b/project.yml @@ -5,6 +5,9 @@ options: postGenCommand: swiftgen packages: + SwiftfulRouting: + url: https://github.com/SwiftfulThinking/SwiftfulRouting + majorVersion: 5.3.5 MartinOMEMO: url: https://github.com/tigase/MartinOMEMO majorVersion: 2.2.3 @@ -53,7 +56,7 @@ targets: # UIUserInterfaceStyle: Light CFBundleDisplayName: Conversations CFBundleShortVersionString: "1.0.0" - CFBundleVersion: "3" + CFBundleVersion: "4" sources: - path: ConversationsClassic excludes: @@ -71,8 +74,11 @@ targets: # keychain-access-groups: imt.narayana.ConversationsClassic.ios dependencies: - sdk: Security.framework + - sdk: CryptoKit.framework # - framework: Lib/WebRTC.xcframework # - target: Engine + - package: SwiftfulRouting + link: true - package: MartinOMEMO link: true - package: KeychainAccess