diff --git a/.gitignore b/.gitignore index 839d127c8fff949c405a77d770e8f22feb8b2b6b..e051e6d6108fed6f8277dbaea33859447b20c2df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.build .DS_Store *.xcuserstate xcuserdata/ diff --git a/Package.swift b/Package.swift index 66b1af7d2459202cf5772e2d22e1009df35777d1..617ab061294b661012d646258bf8cde4c3cf4aef 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.6 import PackageDescription let package = Package( @@ -14,7 +14,6 @@ let package = Package( .library(name: "Shared", targets: ["Shared"]), .library(name: "Models", targets: ["Models"]), .library(name: "XXLogger", targets: ["XXLogger"]), - .library(name: "Database", targets: ["Database"]), .library(name: "Defaults", targets: ["Defaults"]), .library(name: "Bindings", targets: ["Bindings"]), .library(name: "Keychain", targets: ["Keychain"]), @@ -52,96 +51,24 @@ let package = Package( .library(name: "DependencyInjection", targets: ["DependencyInjection"]) ], dependencies: [ - .package( - name: "Quick", - url: "https://github.com/Quick/Quick", - from: "3.0.0" - ), - .package( - name: "DifferenceKit", - url: "https://github.com/ra1028/DifferenceKit", - from: "1.2.0" - ), - .package( - name: "Nimble", - url: "https://github.com/Quick/Nimble", - from: "9.0.0" - ), - .package( - name: "FilesProvider", - url: "https://github.com/amosavian/FileProvider.git", - from: "0.26.0" - ), - .package( - name: "GRDB", - url: "https://github.com/groue/GRDB.swift", - from: "5.3.0" - ), - .package( - name: "GoogleSignIn", - url: "https://github.com/google/GoogleSignIn-iOS", - from: "6.1.0" - ), - .package( - name: "GoogleAPIClientForREST", - url: "https://github.com/google/google-api-objectivec-client-for-rest", - from: "1.6.0" - ), - .package( - name: "SnapKit", - url: "https://github.com/SnapKit/SnapKit", - from: "5.0.1" - ), - .package( - name: "Firebase", - url: "https://github.com/firebase/firebase-ios-sdk.git", - .upToNextMajor(from: "8.10.0") - ), - .package( - name: "SwiftProtobuf", - url: "https://github.com/apple/swift-protobuf", - from: "1.14.0" - ), - .package( - name: "SwiftyDropbox", - url: "https://github.com/dropbox/SwiftyDropbox.git", - from: "8.2.1" - ), - .package( - name: "KeychainAccess", - url: "https://github.com/kishikawakatsumi/KeychainAccess", - from: "4.2.1" - ), - .package( - name: "Retry", - url: "https://github.com/icanzilb/Retry.git", - from: "0.6.3" - ), - .package( - name: "ChatLayout", - url: "https://github.com/ekazaev/ChatLayout", - from: "1.1.14" - ), - .package( - name: "SwiftyBeaver", - url: "https://github.com/SwiftyBeaver/SwiftyBeaver.git", - from: "1.9.5" - ), - .package( - name: "swift-composable-architecture", - url: "https://github.com/pointfreeco/swift-composable-architecture.git", - .upToNextMajor(from: "0.32.0") - ), - .package( - name: "ScrollViewController", - url: "https://github.com/darrarski/ScrollViewController", - from: "1.2.0" - ), - .package( - name: "combine-schedulers", - url: "https://github.com/pointfreeco/combine-schedulers", - from: "0.5.0" - ) + .package(url: "https://github.com/Quick/Quick", from: "3.0.0"), + .package(url: "https://github.com/Quick/Nimble", from: "9.0.0"), + .package(url: "https://github.com/SnapKit/SnapKit", from: "5.0.1"), + .package(url: "https://github.com/icanzilb/Retry.git", from: "0.6.3"), + .package(url: "https://github.com/ekazaev/ChatLayout", from: "1.1.14"), + .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.2.0"), + .package(url: "https://github.com/apple/swift-protobuf", from: "1.14.0"), + .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.1.0"), + .package(url: "https://github.com/dropbox/SwiftyDropbox.git", from: "8.2.1"), + .package(url: "https://github.com/amosavian/FileProvider.git", from: "0.26.0"), + .package(url: "https://github.com/SwiftyBeaver/SwiftyBeaver.git", from: "1.9.5"), + .package(url: "https://github.com/darrarski/ScrollViewController", from: "1.2.0"), + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.5.0"), + .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.1"), + .package(url: "https://github.com/google/google-api-objectivec-client-for-rest", from: "1.6.0"), + .package(url: "https://git.xx.network/elixxir/client-ios-db.git", .upToNextMajor(from: "1.0.5")), + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", .upToNextMajor(from: "8.10.0")), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git",.upToNextMajor(from: "0.32.0")) ], targets: [ .target( @@ -197,7 +124,6 @@ let package = Package( name: "PushFeature", dependencies: [ "Models", - "Database", "Defaults", "Integration", "DependencyInjection" @@ -246,7 +172,7 @@ let package = Package( ), .product( name: "SwiftProtobuf", - package: "SwiftProtobuf" + package: "swift-protobuf" ) ] ), @@ -277,7 +203,7 @@ let package = Package( "CrashReporting", .product( name: "FirebaseCrashlytics", - package: "Firebase" + package: "firebase-ios-sdk" ) ] ), @@ -289,11 +215,11 @@ let package = Package( dependencies: [ .product( name: "GoogleSignIn", - package: "GoogleSignIn" + package: "GoogleSignIn-iOS" ), .product( name: "GoogleAPIClientForREST_Drive", - package: "GoogleAPIClientForREST" + package: "google-api-objectivec-client-for-rest" ) ], resources: [.process("Resources")] @@ -306,7 +232,7 @@ let package = Package( dependencies: [ .product( name: "FilesProvider", - package: "FilesProvider" + package: "FileProvider" ) ] ), @@ -386,40 +312,22 @@ let package = Package( ] ), - // MARK: - Database - - .target( - name: "Database", - dependencies: [ - "Models", - "XXLogger", - .product( - name: "GRDB", - package: "GRDB" - ), - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ) - ] - ), - // MARK: - Shared .target( name: "Shared", dependencies: [ .product( - name: "DifferenceKit", - package: "DifferenceKit" + name: "SnapKit", + package: "SnapKit" ), .product( name: "ChatLayout", package: "ChatLayout" ), .product( - name: "SnapKit", - package: "SnapKit" + name: "DifferenceKit", + package: "DifferenceKit" ) ], exclude: ["swiftgen.yml"], @@ -431,10 +339,10 @@ let package = Package( .target( name: "Integration", dependencies: [ - "XXLogger", "Shared", - "Database", "Bindings", + "XXLogger", + "Keychain", "ToastFeature", "BackupFeature", "CrashReporting", @@ -443,6 +351,14 @@ let package = Package( .product( name: "Retry", package: "Retry" + ), + .product( + name: "XXDatabase", + package: "client-ios-db" + ), + .product( + name: "XXLegacyDatabaseMigrator", + package: "client-ios-db" ) ], resources: [.process("Resources")] @@ -500,13 +416,13 @@ let package = Package( "InputField", "ChatFeature", "Presentation", - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ), .product( name: "CombineSchedulers", package: "combine-schedulers" + ), + .product( + name: "ScrollViewController", + package: "ScrollViewController" ) ] ), @@ -528,14 +444,14 @@ let package = Package( "DrawerFeature", "ChatInputFeature", "DependencyInjection", - .product( - name: "DifferenceKit", - package: "DifferenceKit" - ), .product( name: "ChatLayout", package: "ChatLayout" ), + .product( + name: "DifferenceKit", + package: "DifferenceKit" + ), .product( name: "ScrollViewController", package: "ScrollViewController" @@ -612,13 +528,13 @@ let package = Package( "Presentation", "DrawerFeature", "DependencyInjection", - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ), .product( name: "CombineSchedulers", package: "combine-schedulers" + ), + .product( + name: "ScrollViewController", + package: "ScrollViewController" ) ] ), @@ -662,13 +578,13 @@ let package = Package( "DrawerFeature", "VersionChecking", "DependencyInjection", - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ), .product( name: "CombineSchedulers", package: "combine-schedulers" + ), + .product( + name: "ScrollViewController", + package: "ScrollViewController" ) ] ), @@ -697,9 +613,10 @@ let package = Package( "Models", "InputField", "Presentation", - "GoogleDriveFeature", "iCloudFeature", + "DrawerFeature", "DropboxFeature", + "GoogleDriveFeature", "DependencyInjection" ] ), @@ -760,13 +677,13 @@ let package = Package( "Presentation", "DrawerFeature", "DependencyInjection", - .product( - name: "ScrollViewController", - package: "ScrollViewController" - ), .product( name: "CombineSchedulers", package: "combine-schedulers" + ), + .product( + name: "ScrollViewController", + package: "ScrollViewController" ) ] ), diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index ecb65d1f54eb3b05cda31850eb98431c6ec2d31f..3675b45050f041af457b16d9bc6105633dabab91 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -1,6 +1,7 @@ import UIKit import BackgroundTasks +import XXModels import Theme import XXLogger import Defaults @@ -91,7 +92,10 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { guard UIApplication.shared.backgroundTimeRemaining > 9 else { if !self.forceFailedPendingMessages { self.forceFailedPendingMessages = true - session.forceFailMessages() + + let query = Message.Query(status: [.sending]) + let assignment = Message.Assignments(status: .sendingFailed) + _ = try? session.dbManager.bulkUpdateMessages(query, assignment) } return diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index b342330e9012ba1a245d39cbd26f47602c074db4..9102274af7c0c37ce986b8aba6b2607a33cd66ca 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -259,7 +259,7 @@ extension PushRouter { } case .contactChat(id: let id): if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, - let contact = session.getContactWith(userId: id) { + let contact = try? session.dbManager.fetchContacts(.init(id: [id])).first { navigationController.setViewControllers([ ChatListController(), SingleChatController(contact) @@ -267,7 +267,7 @@ extension PushRouter { } case .groupChat(id: let id): if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, - let info = session.getGroupChatInfoWith(groupId: id) { + let info = try? session.dbManager.fetchGroupInfos(.init(groupId: id)).first { navigationController.setViewControllers([ ChatListController(), GroupChatController(info) diff --git a/Sources/ChatFeature/Controllers/GroupChatController.swift b/Sources/ChatFeature/Controllers/GroupChatController.swift index e248c053bc68431171a9817454d68fffb6f6a4a2..b168e70c438b6660f637758270bc28761dfa5c0c 100644 --- a/Sources/ChatFeature/Controllers/GroupChatController.swift +++ b/Sources/ChatFeature/Controllers/GroupChatController.swift @@ -3,6 +3,7 @@ import Theme import Models import Shared import Combine +import XXModels import Voxophone import ChatLayout import DrawerFeature @@ -32,13 +33,13 @@ public final class GroupChatController: UIViewController { private let layoutDelegate = LayoutDelegate() private var cancellables = Set<AnyCancellable>() private var drawerCancellables = Set<AnyCancellable>() - private var sections = [ArraySection<ChatSection, GroupChatItem>]() + private var sections = [ArraySection<ChatSection, Message>]() private var currentInterfaceActions = SetActor<Set<InterfaceActions>, ReactionTypes>() public override var canBecomeFirstResponder: Bool { true } public override var inputAccessoryView: UIView? { inputComponent } - public init(_ info: GroupChatInfo) { + public init(_ info: GroupInfo) { let viewModel = GroupChatViewModel(info) self.viewModel = viewModel self.members = .init(with: info.members) @@ -59,7 +60,14 @@ public final class GroupChatController: UIViewController { super.init(nibName: nil, bundle: nil) - header.setup(title: info.group.name, memberList: info.members.map { ($0.username, $0.photo) }) + let memberList = info.members.map { + Member( + title: ($0.nickname ?? $0.username) ?? "Fetching username...", + photo: $0.photo + ) + } + + header.setup(title: info.group.name, memberList: memberList) } public required init?(coder: NSCoder) { nil } @@ -154,7 +162,9 @@ public final class GroupChatController: UIViewController { viewModel.replyPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in inputComponent.setupReply(message: $0.text, sender: $0.sender) } + .sink { [unowned self] senderTitle, messageText in + inputComponent.setupReply(message: messageText, sender: senderTitle) + } .store(in: &cancellables) } @@ -317,9 +327,7 @@ extension GroupChatController: UICollectionViewDataSource { let item = sections[indexPath.section].elements[indexPath.item] let canReply: () -> Bool = { - item.status == .sent || - item.status == .received || - item.status == .read + (item.status == .sent || item.status == .received) && item.networkId != nil } let performReply: () -> Void = { [weak self] in @@ -327,21 +335,18 @@ extension GroupChatController: UICollectionViewDataSource { } let name: (Data) -> String = viewModel.getName(from:) - let text: (Data) -> String = viewModel.getText(from:) let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) + let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) if item.status == .received { - if item.payload.reply != nil { + if let replyMessageId = item.replyMessageId { let cell: IncomingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) Bubbler.buildReplyGroup( bubble: cell.leftView, with: item, - reply: .init( - text: text(item.payload.reply!.messageId), - sender: name(item.payload.reply!.senderId) - ), - sender: name(item.sender) + reply: replyContent(replyMessageId), + sender: name(item.senderId) ) cell.canReply = canReply() @@ -351,25 +356,27 @@ extension GroupChatController: UICollectionViewDataSource { return cell } else { let cell: IncomingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.buildGroup(bubble: cell.leftView, with: item, with: name(item.sender)) + Bubbler.buildGroup( + bubble: cell.leftView, + with: item, + with: name(item.senderId) + ) + cell.canReply = canReply() cell.performReply = performReply cell.leftView.didTapShowRound = { showRound(item.roundURL) } return cell } - } else if item.status == .failed { - if item.payload.reply != nil { + } else if item.status == .sendingFailed { + if let replyMessageId = item.replyMessageId { let cell: OutgoingFailedGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) Bubbler.buildReplyGroup( bubble: cell.rightView, with: item, - reply: .init( - text: text(item.payload.reply!.messageId), - sender: name(item.payload.reply!.senderId) - ), - sender: name(item.sender) + reply: replyContent(replyMessageId), + sender: name(item.senderId) ) cell.canReply = canReply() @@ -379,24 +386,26 @@ extension GroupChatController: UICollectionViewDataSource { } else { let cell: OutgoingFailedGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.buildGroup(bubble: cell.rightView, with: item, with: name(item.sender)) + Bubbler.buildGroup( + bubble: cell.rightView, + with: item, + with: name(item.senderId) + ) + cell.canReply = canReply() cell.performReply = performReply return cell } } else { - if item.payload.reply != nil { + if let replyMessageId = item.replyMessageId { let cell: OutgoingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) Bubbler.buildReplyGroup( bubble: cell.rightView, with: item, - reply: .init( - text: text(item.payload.reply!.messageId), - sender: name(item.payload.reply!.senderId) - ), - sender: name(item.sender) + reply: replyContent(replyMessageId), + sender: name(item.senderId) ) cell.canReply = canReply() @@ -407,7 +416,12 @@ extension GroupChatController: UICollectionViewDataSource { } else { let cell: OutgoingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.buildGroup(bubble: cell.rightView, with: item, with: name(item.sender)) + Bubbler.buildGroup( + bubble: cell.rightView, + with: item, + with: name(item.senderId) + ) + cell.canReply = canReply() cell.performReply = performReply cell.rightView.didTapShowRound = { showRound(item.roundURL) } @@ -527,7 +541,7 @@ extension GroupChatController: UICollectionViewDelegate { let item = self.sections[indexPath.section].elements[indexPath.item] let copy = UIAction(title: Localized.Chat.BubbleMenu.copy, state: .off) { _ in - UIPasteboard.general.string = item.payload.text + UIPasteboard.general.string = item.text } let reply = UIAction(title: Localized.Chat.BubbleMenu.reply, state: .off) { [weak self] _ in @@ -544,7 +558,7 @@ extension GroupChatController: UICollectionViewDelegate { let menu: UIMenu - if item.status == .failed { + if item.status == .sendingFailed { menu = UIMenu(title: "", children: [copy, retry, delete]) } else if item.status == .sending { menu = UIMenu(title: "", children: [copy]) diff --git a/Sources/ChatFeature/Controllers/MembersController.swift b/Sources/ChatFeature/Controllers/MembersController.swift index 488ad42517153bfa8fcb492dad11ced22ebf5a6e..e7bf2c3ece345ace155ed7babbf5c9ec05bf1caf 100644 --- a/Sources/ChatFeature/Controllers/MembersController.swift +++ b/Sources/ChatFeature/Controllers/MembersController.swift @@ -1,13 +1,14 @@ import UIKit import Models import Shared +import XXModels final class MembersController: UIViewController { lazy private var stackView = UIStackView() - private let members: [GroupMember] + private let members: [Contact] - init(with members: [GroupMember]) { + init(with members: [Contact]) { self.members = members super.init(nibName: nil, bundle: nil) } @@ -25,16 +26,17 @@ final class MembersController: UIViewController { stackView.distribution = .fillEqually view.addSubview(stackView) - stackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(10) - make.left.right.equalToSuperview() - make.bottom.equalTo(view.safeAreaLayoutGuide) + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.left.right.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide) } - for member in members { + members.forEach { let memberView = MemberView() - memberView.titleLabel.text = member.username - memberView.avatarView.setupProfile(title: member.username, image: member.photo, size: .small) + let assignedTitle = ($0.nickname ?? $0.username) ?? "Fetching username..." + memberView.titleLabel.text = assignedTitle + memberView.avatarView.setupProfile(title: assignedTitle, image: $0.photo, size: .small) stackView.addArrangedSubview(memberView) } } @@ -56,24 +58,24 @@ private final class MemberView: UIView { addSubview(avatarView) addSubview(separatorView) - avatarView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(10) - make.width.height.equalTo(30) - make.left.equalToSuperview().offset(25) - make.centerY.equalToSuperview() + avatarView.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.width.height.equalTo(30) + $0.left.equalToSuperview().offset(25) + $0.centerY.equalToSuperview() } - titleLabel.snp.makeConstraints { make in - make.centerY.equalTo(avatarView) - make.left.equalTo(avatarView.snp.right).offset(14) - make.right.lessThanOrEqualToSuperview().offset(-10) + titleLabel.snp.makeConstraints { + $0.centerY.equalTo(avatarView) + $0.left.equalTo(avatarView.snp.right).offset(14) + $0.right.lessThanOrEqualToSuperview().offset(-10) } - separatorView.snp.makeConstraints { make in - make.height.equalTo(1) - make.left.equalToSuperview().offset(25) - make.right.equalToSuperview() - make.bottom.equalToSuperview() + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.left.equalToSuperview().offset(25) + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } } diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift index b6fd74a15936d7e89da27d03e555e5e16291af3e..b35d314725ea97149e84edb8b9525e1fc010d4f6 100644 --- a/Sources/ChatFeature/Controllers/SingleChatController.swift +++ b/Sources/ChatFeature/Controllers/SingleChatController.swift @@ -1,5 +1,4 @@ import HUD -import DrawerFeature import UIKit import Theme import Models @@ -7,8 +6,10 @@ import Shared import Combine import XXLogger import QuickLook +import XXModels import Voxophone import ChatLayout +import DrawerFeature import DifferenceKit import ChatInputFeature import DependencyInjection @@ -18,6 +19,10 @@ extension FlexibleSpace: CollectionCellContent { func prepareForReuse() {} } +extension Message: Differentiable { + public var differenceIdentifier: Int64 { id! } +} + public final class SingleChatController: UIViewController { @Dependency private var hud: HUDType @Dependency private var logger: XXLogger @@ -43,7 +48,7 @@ public final class SingleChatController: UIViewController { private let layoutDelegate = LayoutDelegate() private var cancellables = Set<AnyCancellable>() private var drawerCancellables = Set<AnyCancellable>() - private var sections = [ArraySection<ChatSection, ChatItem>]() + private var sections = [ArraySection<ChatSection, Message>]() private var currentInterfaceActions: SetActor<Set<InterfaceActions>, ReactionTypes> = SetActor() var fileURL: URL? @@ -153,11 +158,13 @@ public final class SingleChatController: UIViewController { } private func setupNavigationBar(contact: Contact) { - screenView.set(name: contact.nickname ?? contact.username) + screenView.set(name: contact.nickname ?? contact.username!) avatarView.snp.makeConstraints { $0.width.height.equalTo(35) } - avatarView.setupProfile(title: contact.nickname ?? contact.username, image: contact.photo, size: .small) - nameLabel.text = contact.nickname ?? contact.username + let title = (contact.nickname ?? contact.username) ?? "" + avatarView.setupProfile(title: title, image: contact.photo, size: .small) + + nameLabel.text = title nameLabel.textColor = Asset.neutralActive.color nameLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) @@ -203,7 +210,9 @@ public final class SingleChatController: UIViewController { viewModel.replyPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in inputComponent.setupReply(message: $0.text, sender: $0.sender) } + .sink { [unowned self] senderTitle, messageText in + inputComponent.setupReply(message: messageText, sender: senderTitle) + } .store(in: &cancellables) viewModel.navigation @@ -435,8 +444,12 @@ public final class SingleChatController: UIViewController { private func previewItemAt(_ indexPath: IndexPath) { let item = sections[indexPath.section].elements[indexPath.item] - guard let attachment = item.payload.attachment, item.status != .receivingAttachment else { return } - fileURL = FileManager.url(for: "\(attachment.name).\(attachment._extension.written)") + guard let ftid = item.fileTransferId, + item.status != .receiving, + item.status != .receivingFailed else { return } + + let ft = viewModel.getFileTransferWith(id: ftid) + fileURL = FileManager.url(for: "\(ft.name).\(ft.type)") coordinator.toPreview(from: self) } @@ -482,23 +495,22 @@ extension SingleChatController: UICollectionViewDataSource { cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { - let name: (Data) -> String = viewModel.getName(from:) - let text: (Data) -> String = viewModel.getText(from:) let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) let item = sections[indexPath.section].elements[indexPath.item] + let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) let performReply: () -> Void = { [weak self] in self?.viewModel.didRequestReply(item) } let factory = CellFactory.combined(factories: [ - .incomingImage(), - .outgoingImage(), - .incomingAudio(voxophone: voxophone), - .outgoingAudio(voxophone: voxophone), + .incomingImage(transfer: viewModel.getFileTransferWith(id:)), + .outgoingImage(transfer: viewModel.getFileTransferWith(id:)), + .incomingAudio(voxophone: voxophone, transfer: viewModel.getFileTransferWith(id:)), + .outgoingAudio(voxophone: voxophone, transfer: viewModel.getFileTransferWith(id:)), .incomingText(performReply: performReply, showRound: showRound), .outgoingText(performReply: performReply, showRound: showRound), .outgoingFailedText(performReply: performReply), - .incomingReply(performReply: performReply, name: name, text: text, showRound: showRound), - .outgoingReply(performReply: performReply, name: name, text: text, showRound: showRound), - .outgoingFailedReply(performReply: performReply, name: name, text: text) + .incomingReply(performReply: performReply, replyContent: replyContent, showRound: showRound), + .outgoingReply(performReply: performReply, replyContent: replyContent, showRound: showRound), + .outgoingFailedReply(performReply: performReply, replyContent: replyContent) ]) return factory(item: item, collectionView: collectionView, indexPath: indexPath) @@ -561,7 +573,7 @@ extension SingleChatController: UICollectionViewDelegate { let status = sections[section].elements[item].status - if status == .received || status == .read || status == .receivingAttachment { + if status == .received || status == .receiving { var leftView: UIView! if let cell = cell as? IncomingReplyCell { diff --git a/Sources/ChatFeature/Coordinator/ChatCoordinator.swift b/Sources/ChatFeature/Coordinator/ChatCoordinator.swift index 3c04dc1ed0513f95e3fef74248df0fb5edac6185..64a8e46de2eada72bba4b371bed2b1d441a2b846 100644 --- a/Sources/ChatFeature/Coordinator/ChatCoordinator.swift +++ b/Sources/ChatFeature/Coordinator/ChatCoordinator.swift @@ -4,6 +4,7 @@ import Shared import QuickLook import Permissions import Presentation +import XXModels public protocol ChatCoordinating { func toCamera(from: UIViewController) diff --git a/Sources/ChatFeature/Helpers/BubbleBuilder.swift b/Sources/ChatFeature/Helpers/BubbleBuilder.swift index 3f9da31df8b01008102bb6bd066a5b27a69ea326..ae33fd01e60bfaf501583779e11eb59575444b4f 100644 --- a/Sources/ChatFeature/Helpers/BubbleBuilder.swift +++ b/Sources/ChatFeature/Helpers/BubbleBuilder.swift @@ -1,28 +1,29 @@ import UIKit import Shared +import XXModels final class Bubbler { static func build( audioBubble: AudioMessageView, - with item: ChatItem + with item: Message ) { audioBubble.dateLabel.text = item.date.asHoursAndMinutes() switch item.status { - case .received, .read: + case .received: audioBubble.lockerImageView.removeFromSuperview() audioBubble.backgroundColor = Asset.neutralWhite.color audioBubble.dateLabel.textColor = Asset.neutralDisabled.color audioBubble.progressLabel.textColor = Asset.neutralDisabled.color - case .receivingAttachment: + case .receiving: audioBubble.backgroundColor = Asset.neutralWhite.color audioBubble.dateLabel.textColor = Asset.neutralDisabled.color audioBubble.progressLabel.textColor = Asset.neutralDisabled.color - case .timedOut: + case .sendingTimedOut: audioBubble.backgroundColor = Asset.accentWarning.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color - case .failedToSend: + case .sendingFailed: audioBubble.backgroundColor = Asset.accentDanger.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color @@ -30,36 +31,40 @@ final class Bubbler { audioBubble.backgroundColor = Asset.brandBubble.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color - case .sending, .sendingAttachment: + case .sending: audioBubble.backgroundColor = Asset.brandBubble.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color + case .receivingFailed: + audioBubble.backgroundColor = Asset.accentWarning.color + audioBubble.dateLabel.textColor = Asset.neutralWhite.color + audioBubble.progressLabel.textColor = Asset.neutralWhite.color } } static func build( imageBubble: ImageMessageView, - with item: ChatItem + with message: Message, + with transfer: FileTransfer ) { - let progress = item.payload.attachment!.progress - imageBubble.progressLabel.text = String(format: "%.1f%%", progress * 100) - imageBubble.dateLabel.text = item.date.asHoursAndMinutes() + imageBubble.progressLabel.text = String(format: "%.1f%%", transfer.progress * 100) + imageBubble.dateLabel.text = message.date.asHoursAndMinutes() - switch item.status { - case .received, .read: + switch message.status { + case .received: imageBubble.lockerImageView.removeFromSuperview() imageBubble.backgroundColor = Asset.neutralWhite.color imageBubble.dateLabel.textColor = Asset.neutralDisabled.color imageBubble.progressLabel.textColor = Asset.neutralDisabled.color - case .receivingAttachment: + case .receiving: imageBubble.backgroundColor = Asset.neutralWhite.color imageBubble.dateLabel.textColor = Asset.neutralDisabled.color imageBubble.progressLabel.textColor = Asset.neutralDisabled.color - case .failedToSend: + case .sendingFailed: imageBubble.backgroundColor = Asset.accentDanger.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color - case .timedOut: + case .sendingTimedOut: imageBubble.backgroundColor = Asset.accentWarning.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color @@ -67,37 +72,41 @@ final class Bubbler { imageBubble.backgroundColor = Asset.brandBubble.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color - case .sending, .sendingAttachment: + case .sending: imageBubble.backgroundColor = Asset.brandBubble.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color + case .receivingFailed: + imageBubble.backgroundColor = Asset.accentWarning.color + imageBubble.dateLabel.textColor = Asset.neutralWhite.color + imageBubble.progressLabel.textColor = Asset.neutralWhite.color } } static func build( bubble: StackMessageView, - with item: ChatItem + with item: Message ) { - bubble.textView.text = item.payload.text + bubble.textView.text = item.text bubble.senderLabel.removeFromSuperview() bubble.dateLabel.text = item.date.asHoursAndMinutes() let roundButtonColor: UIColor switch item.status { - case .received, .read, .receivingAttachment: + case .received, .receiving: bubble.lockerImageView.removeFromSuperview() bubble.backgroundColor = Asset.neutralWhite.color bubble.textView.textColor = Asset.neutralActive.color bubble.dateLabel.textColor = Asset.neutralDisabled.color roundButtonColor = Asset.neutralDisabled.color bubble.revertBottomStackOrder() - case .timedOut: + case .sendingTimedOut: bubble.backgroundColor = Asset.accentWarning.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color - case .failedToSend: + case .sendingFailed: bubble.backgroundColor = Asset.accentDanger.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color @@ -107,11 +116,16 @@ final class Bubbler { bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color - case .sending, .sendingAttachment: + case .sending: bubble.backgroundColor = Asset.brandBubble.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color + case .receivingFailed: + bubble.backgroundColor = Asset.accentWarning.color + bubble.textView.textColor = Asset.neutralWhite.color + bubble.dateLabel.textColor = Asset.neutralWhite.color + roundButtonColor = Asset.neutralWhite.color } let attrString = NSAttributedString( @@ -127,37 +141,61 @@ final class Bubbler { bubble.roundButton.setAttributedTitle(attrString, for: .normal) } - static func buildGroup( - bubble: StackMessageView, - with item: GroupChatItem, - with senderName: String + static func buildReply( + bubble: ReplyStackMessageView, + with item: Message, + reply: (contactTitle: String, messageText: String) ) { - bubble.textView.text = item.payload.text bubble.dateLabel.text = item.date.asHoursAndMinutes() + bubble.textView.text = item.text + + bubble.replyView.message.text = reply.messageText + bubble.replyView.title.text = reply.contactTitle let roundButtonColor: UIColor switch item.status { - case .received, .read: - bubble.senderLabel.text = senderName + case .received, .receiving: + bubble.senderLabel.removeFromSuperview() bubble.backgroundColor = Asset.neutralWhite.color bubble.textView.textColor = Asset.neutralActive.color bubble.dateLabel.textColor = Asset.neutralDisabled.color roundButtonColor = Asset.neutralDisabled.color - bubble.lockerImageView.removeFromSuperview() + bubble.replyView.container.backgroundColor = Asset.brandDefault.color + bubble.replyView.space.backgroundColor = Asset.brandPrimary.color bubble.revertBottomStackOrder() - case .failed: + case .sendingTimedOut: + bubble.senderLabel.removeFromSuperview() + bubble.backgroundColor = Asset.accentWarning.color + bubble.textView.textColor = Asset.neutralWhite.color + bubble.dateLabel.textColor = Asset.neutralWhite.color + roundButtonColor = Asset.neutralWhite.color + bubble.replyView.space.backgroundColor = Asset.neutralWhite.color + bubble.replyView.container.backgroundColor = Asset.brandLight.color + case .sendingFailed: bubble.senderLabel.removeFromSuperview() bubble.backgroundColor = Asset.accentDanger.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color + bubble.replyView.space.backgroundColor = Asset.neutralWhite.color + bubble.replyView.container.backgroundColor = Asset.brandLight.color case .sent, .sending: bubble.senderLabel.removeFromSuperview() + bubble.textView.textColor = Asset.neutralWhite.color bubble.backgroundColor = Asset.brandBubble.color + bubble.dateLabel.textColor = Asset.neutralWhite.color + roundButtonColor = Asset.neutralWhite.color + bubble.replyView.space.backgroundColor = Asset.neutralWhite.color + bubble.replyView.container.backgroundColor = Asset.brandLight.color + case .receivingFailed: + bubble.senderLabel.removeFromSuperview() bubble.textView.textColor = Asset.neutralWhite.color + bubble.backgroundColor = Asset.accentWarning.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color + bubble.replyView.space.backgroundColor = Asset.neutralWhite.color + bubble.replyView.container.backgroundColor = Asset.brandLight.color } let attrString = NSAttributedString( @@ -169,53 +207,54 @@ final class Bubbler { .font: Fonts.Mulish.regular.font(size: 12.0) as Any ] ) - bubble.roundButton.setAttributedTitle(attrString, for: .normal) } - static func buildReply( + static func buildReplyGroup( bubble: ReplyStackMessageView, - with item: ChatItem, - reply: ReplyModel + with item: Message, + reply: (contactTitle: String, messageText: String), + sender: String ) { bubble.dateLabel.text = item.date.asHoursAndMinutes() - bubble.textView.text = item.payload.text + bubble.textView.text = item.text - bubble.replyView.message.text = reply.text - bubble.replyView.title.text = reply.sender + bubble.replyView.message.text = reply.messageText + bubble.replyView.title.text = reply.contactTitle let roundButtonColor: UIColor switch item.status { - case .received, .read, .receivingAttachment: - bubble.senderLabel.removeFromSuperview() + case .received, .receiving: + bubble.senderLabel.text = sender bubble.backgroundColor = Asset.neutralWhite.color bubble.textView.textColor = Asset.neutralActive.color bubble.dateLabel.textColor = Asset.neutralDisabled.color roundButtonColor = Asset.neutralDisabled.color bubble.replyView.container.backgroundColor = Asset.brandDefault.color bubble.replyView.space.backgroundColor = Asset.brandPrimary.color + bubble.lockerImageView.removeFromSuperview() bubble.revertBottomStackOrder() - case .timedOut: + case .sendingFailed, .sendingTimedOut: bubble.senderLabel.removeFromSuperview() - bubble.backgroundColor = Asset.accentWarning.color + bubble.backgroundColor = Asset.accentDanger.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color bubble.replyView.space.backgroundColor = Asset.neutralWhite.color bubble.replyView.container.backgroundColor = Asset.brandLight.color - case .failedToSend: + case .sent, .sending: bubble.senderLabel.removeFromSuperview() - bubble.backgroundColor = Asset.accentDanger.color bubble.textView.textColor = Asset.neutralWhite.color + bubble.backgroundColor = Asset.brandBubble.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color bubble.replyView.space.backgroundColor = Asset.neutralWhite.color bubble.replyView.container.backgroundColor = Asset.brandLight.color - case .sent, .sending, .sendingAttachment: + case .receivingFailed: bubble.senderLabel.removeFromSuperview() bubble.textView.textColor = Asset.neutralWhite.color - bubble.backgroundColor = Asset.brandBubble.color + bubble.backgroundColor = Asset.accentWarning.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color bubble.replyView.space.backgroundColor = Asset.neutralWhite.color @@ -231,50 +270,47 @@ final class Bubbler { .font: Fonts.Mulish.regular.font(size: 12.0) as Any ] ) + bubble.roundButton.setAttributedTitle(attrString, for: .normal) } - static func buildReplyGroup( - bubble: ReplyStackMessageView, - with item: GroupChatItem, - reply: ReplyModel, - sender: String + static func buildGroup( + bubble: StackMessageView, + with item: Message, + with senderName: String ) { + bubble.textView.text = item.text bubble.dateLabel.text = item.date.asHoursAndMinutes() - bubble.textView.text = item.payload.text - - bubble.replyView.message.text = reply.text - bubble.replyView.title.text = reply.sender let roundButtonColor: UIColor switch item.status { - case .received, .read: - bubble.senderLabel.text = sender + case .received, .receiving: + bubble.senderLabel.text = senderName bubble.backgroundColor = Asset.neutralWhite.color bubble.textView.textColor = Asset.neutralActive.color bubble.dateLabel.textColor = Asset.neutralDisabled.color roundButtonColor = Asset.neutralDisabled.color - bubble.replyView.container.backgroundColor = Asset.brandDefault.color - bubble.replyView.space.backgroundColor = Asset.brandPrimary.color bubble.lockerImageView.removeFromSuperview() bubble.revertBottomStackOrder() - case .failed: + case .sendingFailed, .sendingTimedOut: bubble.senderLabel.removeFromSuperview() bubble.backgroundColor = Asset.accentDanger.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color - bubble.replyView.space.backgroundColor = Asset.neutralWhite.color - bubble.replyView.container.backgroundColor = Asset.brandLight.color case .sent, .sending: bubble.senderLabel.removeFromSuperview() - bubble.textView.textColor = Asset.neutralWhite.color bubble.backgroundColor = Asset.brandBubble.color + bubble.textView.textColor = Asset.neutralWhite.color + bubble.dateLabel.textColor = Asset.neutralWhite.color + roundButtonColor = Asset.neutralWhite.color + case .receivingFailed: + bubble.senderLabel.removeFromSuperview() + bubble.backgroundColor = Asset.accentWarning.color + bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color - bubble.replyView.space.backgroundColor = Asset.neutralWhite.color - bubble.replyView.container.backgroundColor = Asset.brandLight.color } let attrString = NSAttributedString( @@ -289,4 +325,6 @@ final class Bubbler { bubble.roundButton.setAttributedTitle(attrString, for: .normal) } + + } diff --git a/Sources/ChatFeature/Helpers/CellConfigurator.swift b/Sources/ChatFeature/Helpers/CellConfigurator.swift index 5f2cb1b6b3348382304af392042bae3184010302..c59050a9221da32149bda8432c69d9271f08a0b7 100644 --- a/Sources/ChatFeature/Helpers/CellConfigurator.swift +++ b/Sources/ChatFeature/Helpers/CellConfigurator.swift @@ -1,16 +1,17 @@ import UIKit import Shared import Combine +import XXModels import Voxophone import AVFoundation struct CellFactory { - var canBuild: (ChatItem) -> Bool + var canBuild: (Message) -> Bool - var build: (ChatItem, UICollectionView, IndexPath) -> UICollectionViewCell + var build: (Message, UICollectionView, IndexPath) -> UICollectionViewCell func callAsFunction( - item: ChatItem, + item: Message, collectionView: UICollectionView, indexPath: IndexPath ) -> UICollectionViewCell { @@ -39,33 +40,34 @@ extension CellFactory { extension CellFactory { static func incomingAudio( - voxophone: Voxophone + voxophone: Voxophone, + transfer: @escaping (Data) -> FileTransfer ) -> Self { .init( canBuild: { item in - (item.status == .received || item.status == .read || item.status == .receivingAttachment) - && item.payload.reply == nil - && item.payload.attachment != nil - && item.payload.attachment?._extension == .audio + guard (item.status == .received || item.status == .receiving), + item.replyMessageId == nil, + item.fileTransferId != nil else { return false } - }, build: { item, collectionView, indexPath in - guard let attachment = item.payload.attachment else { fatalError() } + return transfer(item.fileTransferId!).type == "m4a" + }, build: { item, collectionView, indexPath in + let ft = transfer(item.fileTransferId!) let cell: IncomingAudioCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - let url = FileManager.url(for: "\(attachment.name).\(attachment._extension.written)")! + let url = FileManager.url(for: ft.name)! var model = AudioMessageCellState( date: item.date, audioURL: url, isPlaying: false, - transferProgress: attachment.progress, + transferProgress: ft.progress, isLoudspeaker: false, duration: (try? AVAudioPlayer(contentsOf: url).duration) ?? 0.0, playbackTime: 0.0 ) cell.leftView.setup(with: model) - cell.canReply = item.status.canReply + cell.canReply = false cell.performReply = {} Bubbler.build(audioBubble: cell.leftView, with: item) @@ -87,13 +89,13 @@ extension CellFactory { }.store(in: &cell.leftView.cancellables) cell.leftView.didTapRight = { - guard item.status != .receivingAttachment else { return } + guard item.status != .receiving else { return } voxophone.toggleLoudspeaker() } cell.leftView.didTapLeft = { - guard item.status != .receivingAttachment else { return } + guard item.status != .receiving else { return } if case .playing(url, _, _, _) = voxophone.state { voxophone.reset() @@ -109,35 +111,38 @@ extension CellFactory { } static func outgoingAudio( - voxophone: Voxophone + voxophone: Voxophone, + transfer: @escaping (Data) -> FileTransfer ) -> Self { .init( canBuild: { item in - (item.status == .sent || - item.status == .failedToSend || - item.status == .sendingAttachment || - item.status == .timedOut) - && item.payload.reply == nil - && item.payload.attachment != nil - && item.payload.attachment?._extension == .audio + guard (item.status == .sent || + item.status == .sending || + item.status == .sendingFailed || + item.status == .sendingTimedOut) + && item.replyMessageId == nil + && item.fileTransferId != nil else { + return false + } - }, build: { item, collectionView, indexPath in - guard let attachment = item.payload.attachment else { fatalError() } + return transfer(item.fileTransferId!).type == "m4a" + }, build: { item, collectionView, indexPath in + let ft = transfer(item.fileTransferId!) let cell: OutgoingAudioCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - let url = FileManager.url(for: "\(attachment.name).\(attachment._extension.written)")! + let url = FileManager.url(for: ft.name)! var model = AudioMessageCellState( date: item.date, audioURL: url, isPlaying: false, - transferProgress: attachment.progress, + transferProgress: ft.progress, isLoudspeaker: false, duration: (try? AVAudioPlayer(contentsOf: url).duration) ?? 0.0, playbackTime: 0.0 ) cell.rightView.setup(with: model) - cell.canReply = item.status.canReply + cell.canReply = false cell.performReply = {} Bubbler.build(audioBubble: cell.rightView, with: item) @@ -178,28 +183,31 @@ extension CellFactory { } extension CellFactory { - static func outgoingImage() -> Self { + static func outgoingImage( + transfer: @escaping (Data) -> FileTransfer + ) -> Self { .init( canBuild: { item in - (item.status == .sent || - item.status == .failedToSend || - item.status == .sendingAttachment || - item.status == .timedOut) - && item.payload.reply == nil - && item.payload.attachment != nil - && item.payload.attachment?._extension == .image + guard (item.status == .sent || + item.status == .sending || + item.status == .sendingFailed || + item.status == .sendingTimedOut) + && item.replyMessageId == nil + && item.fileTransferId != nil else { + return false + } - }, build: { item, collectionView, indexPath in - guard let attachment = item.payload.attachment else { fatalError() } + return transfer(item.fileTransferId!).type == "jpeg" + }, build: { item, collectionView, indexPath in + let ft = transfer(item.fileTransferId!) let cell: OutgoingImageCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.build(imageBubble: cell.rightView, with: item) - - cell.canReply = item.status.canReply + Bubbler.build(imageBubble: cell.rightView, with: item, with: transfer(item.fileTransferId!)) + cell.canReply = false cell.performReply = {} - if let image = UIImage(data: attachment.data!) { + if let image = UIImage(data: ft.data!) { cell.rightView.imageView.image = UIImage(cgImage: image.cgImage!, scale: image.scale, orientation: .up) } @@ -208,23 +216,33 @@ extension CellFactory { ) } - static func incomingImage() -> Self { + static func incomingImage( + transfer: @escaping (Data) -> FileTransfer + ) -> Self { .init( canBuild: { item in - (item.status == .received || item.status == .read || item.status == .receivingAttachment) - && item.payload.reply == nil - && item.payload.attachment != nil - && item.payload.attachment?._extension == .image + guard (item.status == .received || item.status == .receiving) + && item.replyMessageId == nil + && item.fileTransferId != nil else { + return false + } - }, build: { item, collectionView, indexPath in - guard let attachment = item.payload.attachment else { fatalError() } + return transfer(item.fileTransferId!).type == "jpeg" + }, build: { item, collectionView, indexPath in + let ft = transfer(item.fileTransferId!) let cell: IncomingImageCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.build(imageBubble: cell.leftView, with: item) - cell.canReply = item.status.canReply + Bubbler.build(imageBubble: cell.leftView, with: item, with: ft) + cell.canReply = false cell.performReply = {} - cell.leftView.imageView.image = UIImage(data: attachment.data!) + + if let data = ft.data { + cell.leftView.imageView.image = UIImage(data: data) + } else { + cell.leftView.imageView.image = Asset.transferImagePlaceholder.image + } + return cell } ) @@ -234,15 +252,13 @@ extension CellFactory { extension CellFactory { static func outgoingReply( performReply: @escaping () -> Void, - name: @escaping (Data) -> String, - text: @escaping (Data) -> String, + replyContent: @escaping (Data) -> (String, String), showRound: @escaping (String?) -> Void ) -> Self { .init( canBuild: { item in (item.status == .sent || item.status == .sending) - && item.payload.reply != nil - && item.payload.attachment == nil + && item.replyMessageId != nil }, build: { item, collectionView, indexPath in let cell: OutgoingReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) @@ -250,13 +266,10 @@ extension CellFactory { Bubbler.buildReply( bubble: cell.rightView, with: item, - reply: .init( - text: text(item.payload.reply!.messageId), - sender: name(item.payload.reply!.senderId) - ) + reply: replyContent(item.replyMessageId!) ) - cell.canReply = item.status.canReply + cell.canReply = item.status == .sent cell.performReply = performReply cell.rightView.didTapShowRound = { showRound(item.roundURL) } return cell @@ -266,15 +279,13 @@ extension CellFactory { static func incomingReply( performReply: @escaping () -> Void, - name: @escaping (Data) -> String, - text: @escaping (Data) -> String, + replyContent: @escaping (Data) -> (String, String), showRound: @escaping (String?) -> Void ) -> Self { .init( canBuild: { item in - (item.status == .received || item.status == .read) - && item.payload.reply != nil - && item.payload.attachment == nil + item.status == .received + && item.replyMessageId != nil }, build: { item, collectionView, indexPath in let cell: IncomingReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) @@ -282,12 +293,9 @@ extension CellFactory { Bubbler.buildReply( bubble: cell.leftView, with: item, - reply: .init( - text: text(item.payload.reply!.messageId), - sender: name(item.payload.reply!.senderId) - ) + reply: replyContent(item.replyMessageId!) ) - cell.canReply = item.status.canReply + cell.canReply = item.status == .received cell.performReply = performReply cell.leftView.didTapShowRound = { showRound(item.roundURL) } cell.leftView.revertBottomStackOrder() @@ -298,14 +306,12 @@ extension CellFactory { static func outgoingFailedReply( performReply: @escaping () -> Void, - name: @escaping (Data) -> String, - text: @escaping (Data) -> String + replyContent: @escaping (Data) -> (String, String) ) -> Self { .init( canBuild: { item in - (item.status == .failedToSend || item.status == .timedOut) - && item.payload.reply != nil - && item.payload.attachment == nil + (item.status == .sendingFailed || item.status == .sendingTimedOut) + && item.replyMessageId != nil }, build: { item, collectionView, indexPath in let cell: OutgoingFailedReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) @@ -313,13 +319,10 @@ extension CellFactory { Bubbler.buildReply( bubble: cell.rightView, with: item, - reply: .init( - text: text(item.payload.reply!.messageId), - sender: name(item.payload.reply!.senderId) - ) + reply: replyContent(item.replyMessageId!) ) - cell.canReply = item.status.canReply + cell.canReply = false cell.performReply = performReply return cell } @@ -334,15 +337,14 @@ extension CellFactory { ) -> Self { .init( canBuild: { item in - (item.status == .received || item.status == .read) - && item.payload.reply == nil - && item.payload.attachment == nil + item.status == .received + && item.replyMessageId == nil }, build: { item, collectionView, indexPath in let cell: IncomingTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) Bubbler.build(bubble: cell.leftView, with: item) - cell.canReply = item.status.canReply + cell.canReply = item.status == .received cell.performReply = performReply cell.leftView.didTapShowRound = { showRound(item.roundURL) } cell.leftView.revertBottomStackOrder() @@ -358,14 +360,13 @@ extension CellFactory { .init( canBuild: { item in (item.status == .sending || item.status == .sent) - && item.payload.reply == nil - && item.payload.attachment == nil + && item.replyMessageId == nil }, build: { item, collectionView, indexPath in let cell: OutgoingTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) Bubbler.build(bubble: cell.rightView, with: item) - cell.canReply = item.status.canReply + cell.canReply = item.status == .sent cell.performReply = performReply cell.rightView.didTapShowRound = { showRound(item.roundURL) } @@ -377,15 +378,14 @@ extension CellFactory { static func outgoingFailedText(performReply: @escaping () -> Void) -> Self { .init( canBuild: { item in - (item.status == .failedToSend || item.status == .timedOut) - && item.payload.reply == nil - && item.payload.attachment == nil + (item.status == .sendingFailed || item.status == .sendingTimedOut) + && item.replyMessageId == nil }, build: { item, collectionView, indexPath in let cell: OutgoingFailedTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) Bubbler.build(bubble: cell.rightView, with: item) - cell.canReply = item.status.canReply + cell.canReply = false cell.performReply = performReply return cell } @@ -416,18 +416,16 @@ struct ActionFactory { } static func build( - from item: ChatItem, + from item: Message, action: Action, - closure: @escaping (ChatItem) -> Void + closure: @escaping (Message) -> Void ) -> UIAction? { - guard item.payload.attachment == nil else { return nil } - switch action { case .reply: - guard item.status == .read || item.status == .received || item.status == .sent else { return nil } + guard item.status == .received || item.status == .sent else { return nil } case .retry: - guard item.status == .failedToSend || item.status == .timedOut else { return nil } + guard item.status == .sendingFailed || item.status == .sendingTimedOut else { return nil } case .delete, .copy: break } diff --git a/Sources/ChatFeature/Models/ChatItem.swift b/Sources/ChatFeature/Models/ChatItem.swift deleted file mode 100644 index 9e68dcf4c13386cd04d390d2530007db6c8cdb5c..0000000000000000000000000000000000000000 --- a/Sources/ChatFeature/Models/ChatItem.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Models -import Foundation -import DifferenceKit - -struct ChatItem: Equatable, Differentiable { - let date: Date - var uniqueId: Data? - let identity: Int64 - var roundURL: String? - let payload: Payload - var status: Message.Status - var differenceIdentifier: Int64 { identity } - - init(_ message: Message) { - self.identity = message.id! - self.status = message.status - self.payload = message.payload - self.roundURL = message.roundURL - self.uniqueId = message.uniqueId - self.date = Date.fromTimestamp(message.timestamp) - } -} - -struct GroupChatItem: Equatable, Differentiable { - let date: Date - let sender: Data - let identity: Int64 - var uniqueId: Data? - var roundURL: String? - let payload: Payload - var status: GroupMessage.Status - var differenceIdentifier: Int64 { identity } - - init(_ groupMessage: GroupMessage) { - self.identity = groupMessage.id! - self.status = groupMessage.status - self.roundURL = groupMessage.roundURL - self.sender = groupMessage.sender - self.payload = groupMessage.payload - self.uniqueId = groupMessage.uniqueId - self.date = Date.fromTimestamp(groupMessage.timestamp) - } -} diff --git a/Sources/ChatFeature/Models/ChatSection.swift b/Sources/ChatFeature/Models/ChatSection.swift index cb755bf94795c6a69eb9549b5cacbb023aca1e65..a91c016fd166a1f7b2e41286186dd2cae1f2b340 100644 --- a/Sources/ChatFeature/Models/ChatSection.swift +++ b/Sources/ChatFeature/Models/ChatSection.swift @@ -2,11 +2,6 @@ import Foundation import DifferenceKit struct ChatSection: Equatable, Differentiable { - // MARK: Properties - var date: Date - - // MARK: DifferenceKit - var differenceIdentifier: Date { date } } diff --git a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift index ad29c602f94972faaec2aec031838e81b45c2ca6..aa1dbefc8ed34f582d84df609d70b65631c69013 100644 --- a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift @@ -1,6 +1,7 @@ import UIKit import Models import Combine +import XXModels import Foundation import Integration import DifferenceKit @@ -14,7 +15,7 @@ enum GroupChatNavigationRoutes: Equatable { final class GroupChatViewModel { @Dependency private var session: SessionType - var replyPublisher: AnyPublisher<ReplyModel, Never> { + var replyPublisher: AnyPublisher<(String, String), Never> { replySubject.eraseToAnyPublisher() } @@ -22,17 +23,17 @@ final class GroupChatViewModel { routesSubject.eraseToAnyPublisher() } - let info: GroupChatInfo + let info: GroupInfo private var stagedReply: Reply? private var cancellables = Set<AnyCancellable>() - private let replySubject = PassthroughSubject<ReplyModel, Never>() + private let replySubject = PassthroughSubject<(String, String), Never>() private let routesSubject = PassthroughSubject<GroupChatNavigationRoutes, Never>() - var messages: AnyPublisher<[ArraySection<ChatSection, GroupChatItem>], Never> { - session.groupMessages(info.group) - .map { messages -> [ArraySection<ChatSection, GroupChatItem>] in - let domainModels = messages.map { GroupChatItem($0) } - let groupedByDate = Dictionary(grouping: domainModels) { domainModel -> Date in + var messages: AnyPublisher<[ArraySection<ChatSection, Message>], Never> { + session.dbManager.fetchMessagesPublisher(.init(chat: .group(info.group.id))) + .assertNoFailure() + .map { messages -> [ArraySection<ChatSection, Message>] in + let groupedByDate = Dictionary(grouping: messages) { domainModel -> Date in let components = Calendar.current.dateComponents([.day, .month, .year], from: domainModel.date) return Calendar.current.date(from: components)! } @@ -41,36 +42,38 @@ final class GroupChatViewModel { .map { .init(model: ChatSection(date: $0.key), elements: $0.value) } .sorted(by: { $0.model.date < $1.model.date }) } - .map { sections -> [ArraySection<ChatSection, GroupChatItem>] in - var snapshot = [ArraySection<ChatSection, GroupChatItem>]() + .map { sections -> [ArraySection<ChatSection, Message>] in + var snapshot = [ArraySection<ChatSection, Message>]() sections.forEach { snapshot.append(.init(model: $0.model, elements: $0.elements)) } return snapshot }.eraseToAnyPublisher() } - init(_ info: GroupChatInfo) { + init(_ info: GroupInfo) { self.info = info } func readAll() { - session.readAll(from: info.group) + let assignment = Message.Assignments(isUnread: false) + let query = Message.Query(chat: .group(info.group.id)) + _ = try? session.dbManager.bulkUpdateMessages(query, assignment) } - func didRequestDelete(_ items: [GroupChatItem]) { - session.delete(groupMessages: items.map { $0.identity }) + func didRequestDelete(_ messages: [Message]) { + _ = try? session.dbManager.deleteMessages(.init(id: Set(messages.map(\.id)))) } func send(_ text: String) { session.send(.init( text: text.trimmingCharacters(in: .whitespacesAndNewlines), - reply: stagedReply, - attachment: nil + reply: stagedReply ), toGroup: info.group) stagedReply = nil } - func retry(_ model: GroupChatItem) { - session.retryGroupMessage(model.identity) + func retry(_ message: Message) { + guard let id = message.id else { return } + session.retryMessage(id) } func showRoundFrom(_ roundURL: String?) { @@ -85,19 +88,27 @@ final class GroupChatViewModel { stagedReply = nil } - func getName(from senderId: Data) -> String { - guard let member = info.members.first(where: { $0.userId == senderId }) else { return "You" } - return member.username - } + func getReplyContent(for messageId: Data) -> (String, String) { + guard let message = try? session.dbManager.fetchMessages(.init(networkId: messageId)).first else { + return ("[DELETED]", "[DELETED]") + } - func getText(from messageId: Data) -> String { - session.getTextFromGroupMessage(messageId: messageId) ?? "[DELETED]" + return (getName(from: message.senderId), message.text) } - func didRequestReply(_ model: GroupChatItem) { - guard let messageId = model.uniqueId else { return } + func getName(from senderId: Data) -> String { + guard senderId != session.myId else { return "You" } + + guard let contact = try? session.dbManager.fetchContacts(.init(id: [senderId])).first else { + return "[DELETED]" + } + + return (contact.nickname ?? contact.username) ?? "Fetching username..." + } - stagedReply = Reply(messageId: messageId, senderId: model.sender) - replySubject.send(.init(text: model.payload.text, sender: getName(from: model.sender))) + func didRequestReply(_ message: Message) { + guard let networkId = message.networkId else { return } + stagedReply = Reply(messageId: networkId, senderId: message.senderId) + replySubject.send(getReplyContent(for: networkId)) } } diff --git a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift index d1be4b6ee2b69fb786f1d371162c4f4d8c420f1c..cd78765910d84d7242ee96ff145c3cd612c2492d 100644 --- a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift @@ -4,17 +4,13 @@ import Models import Shared import Combine import XXLogger +import XXModels import Foundation import Integration import Permissions import DifferenceKit import DependencyInjection -struct ReplyModel { - var text: String - var sender: String -} - enum SingleChatNavigationRoutes: Equatable { case none case camera @@ -35,22 +31,22 @@ final class SingleChatViewModel { private var stagedReply: Reply? private var cancellables = Set<AnyCancellable>() private let contactSubject: CurrentValueSubject<Contact, Never> - private let replySubject = PassthroughSubject<ReplyModel, Never>() + private let replySubject = PassthroughSubject<(String, String), Never>() private let navigationRoutes = PassthroughSubject<SingleChatNavigationRoutes, Never>() - private let sectionsRelay = CurrentValueSubject<[ArraySection<ChatSection, ChatItem>], Never>([]) + private let sectionsRelay = CurrentValueSubject<[ArraySection<ChatSection, Message>], Never>([]) var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) var isOnline: AnyPublisher<Bool, Never> { session.isOnline } var contactPublisher: AnyPublisher<Contact, Never> { contactSubject.eraseToAnyPublisher() } - var replyPublisher: AnyPublisher<ReplyModel, Never> { replySubject.eraseToAnyPublisher() } + var replyPublisher: AnyPublisher<(String, String), Never> { replySubject.eraseToAnyPublisher() } var navigation: AnyPublisher<SingleChatNavigationRoutes, Never> { navigationRoutes.eraseToAnyPublisher() } var shouldDisplayEmptyView: AnyPublisher<Bool, Never> { sectionsRelay.map { $0.isEmpty }.eraseToAnyPublisher() } - var messages: AnyPublisher<[ArraySection<ChatSection, ChatItem>], Never> { - sectionsRelay.map { sections -> [ArraySection<ChatSection, ChatItem>] in - var snapshot = [ArraySection<ChatSection, ChatItem>]() + var messages: AnyPublisher<[ArraySection<ChatSection, Message>], Never> { + sectionsRelay.map { sections -> [ArraySection<ChatSection, Message>] in + var snapshot = [ArraySection<ChatSection, Message>]() sections.forEach { snapshot.append(.init(model: $0.model, elements: $0.elements)) } return snapshot }.eraseToAnyPublisher() @@ -60,7 +56,7 @@ final class SingleChatViewModel { if contact.isRecent == true { var contact = contact contact.isRecent = false - session.update(contact) + _ = try? session.dbManager.saveContact(contact) } } @@ -73,17 +69,16 @@ final class SingleChatViewModel { updateRecentState(contact) - session.contacts(.withUserId(contact.userId)) + session.dbManager.fetchContactsPublisher(Contact.Query(id: [contact.id])) + .assertNoFailure() .compactMap { $0.first } .sink { [unowned self] in contactSubject.send($0) } .store(in: &cancellables) - session.singleMessages(contact) - .map { $0.sorted(by: { $0.timestamp < $1.timestamp }) } - .map { messages in - - let domainModels = messages.map { ChatItem($0) } - let groupedByDate = Dictionary(grouping: domainModels) { domainModel -> Date in + session.dbManager.fetchMessagesPublisher(.init(chat: .direct(session.myId, contact.id))) + .assertNoFailure() + .map { + let groupedByDate = Dictionary(grouping: $0) { domainModel -> Date in let components = Calendar.current.dateComponents([.day, .month, .year], from: domainModel.date) return Calendar.current.date(from: components)! } @@ -98,13 +93,16 @@ final class SingleChatViewModel { // MARK: Public - func didSendAudio(url: URL) { - let name = url.deletingPathExtension().lastPathComponent - guard let file = FileManager.retrieve(name: name, type: Attachment.Extension.audio.written) else { return } + func getFileTransferWith(id: Data) -> FileTransfer { + guard let transfer = try? session.dbManager.fetchFileTransfers(.init(id: [id])).first else { + fatalError() + } - let attachment = Attachment(name: name, data: file, _extension: .audio) - let payload = Payload(text: "You sent a voice message", reply: nil, attachment: attachment) - session.send(payload, toContact: contact) + return transfer + } + + func didSendAudio(url: URL) { + session.sendFile(url: url, to: contact) } func didSend(image: UIImage) { @@ -122,15 +120,18 @@ final class SingleChatViewModel { } func readAll() { - session.readAll(from: contact) + let assignment = Message.Assignments(isUnread: false) + let query = Message.Query(chat: .direct(session.myId, contact.id)) + _ = try? session.dbManager.bulkUpdateMessages(query, assignment) } func didRequestDeleteAll() { - session.deleteAll(from: contact) + _ = try? session.dbManager.deleteMessages(.init(chat: .direct(session.myId, contact.id))) } - func didRequestRetry(_ model: ChatItem) { - session.retryMessage(model.identity) + func didRequestRetry(_ message: Message) { + guard let id = message.id else { return } + session.retryMessage(id) } func didNavigateSomewhere() { @@ -163,11 +164,11 @@ final class SingleChatViewModel { return false } - func didRequestCopy(_ model: ChatItem) { - UIPasteboard.general.string = model.payload.text + func didRequestCopy(_ model: Message) { + UIPasteboard.general.string = model.text } - func didRequestDeleteSingle(_ model: ChatItem) { + func didRequestDeleteSingle(_ model: Message) { didRequestDelete([model]) } @@ -177,21 +178,37 @@ final class SingleChatViewModel { func send(_ string: String) { let text = string.trimmingCharacters(in: .whitespacesAndNewlines) - let payload = Payload(text: text, reply: stagedReply, attachment: nil) + let payload = Payload(text: text, reply: stagedReply) session.send(payload, toContact: contact) stagedReply = nil } - func didRequestReply(_ model: ChatItem) { - guard let messageId = model.uniqueId else { return } + func didRequestReply(_ message: Message) { + guard let networkId = message.networkId else { return } - let isIncoming = model.status == .received || model.status == .read - stagedReply = Reply(messageId: messageId, senderId: isIncoming ? contact.userId : session.myId) - replySubject.send(.init(text: model.payload.text, sender: isIncoming ? contact.nickname ?? contact.username : "You")) + let senderTitle: String = { + if message.senderId == session.myId { + return "You" + } else { + return (contact.nickname ?? contact.username) ?? "Fetching username..." + } + }() + + replySubject.send((senderTitle, message.text)) + stagedReply = Reply(messageId: networkId, senderId: message.senderId) } - func getText(from messageId: Data) -> String { - session.getTextFromMessage(messageId: messageId) ?? "[DELETED]" + func getReplyContent(for messageId: Data) -> (String, String) { + guard let message = try? session.dbManager.fetchMessages(.init(networkId: messageId)).first else { + return ("[DELETED]", "[DELETED]") + } + + guard let contact = try? session.dbManager.fetchContacts(.init(id: [message.senderId])).first else { + fatalError() + } + + let contactTitle = (contact.nickname ?? contact.username) ?? "You" + return (contactTitle, message.text) } func showRoundFrom(_ roundURL: String?) { @@ -202,19 +219,15 @@ final class SingleChatViewModel { } } - func didRequestDelete(_ items: [ChatItem]) { - session.delete(messages: items.map { $0.identity }) - } - - func itemWith(id: Int64) -> ChatItem? { - sectionsRelay.value.flatMap(\.elements).first(where: { $0.identity == id }) + func didRequestDelete(_ items: [Message]) { + _ = try? session.dbManager.deleteMessages(.init(id: Set(items.compactMap(\.id)))) } - func getName(from senderId: Data) -> String { - senderId == session.myId ? "You" : contact.nickname ?? contact.username + func itemWith(id: Int64) -> Message? { + sectionsRelay.value.flatMap(\.elements).first(where: { $0.id == id }) } - func itemAt(indexPath: IndexPath) -> ChatItem? { + func itemAt(indexPath: IndexPath) -> Message? { guard sectionsRelay.value.count > indexPath.section else { return nil } let items = sectionsRelay.value[indexPath.section].elements diff --git a/Sources/ChatFeature/Views/GroupHeaderView.swift b/Sources/ChatFeature/Views/GroupHeaderView.swift index d85a49abe97265a8b0ba5bd1dec227b45e97c9ec..b33415707304f070f172764a5ee8a49d89a1a10d 100644 --- a/Sources/ChatFeature/Views/GroupHeaderView.swift +++ b/Sources/ChatFeature/Views/GroupHeaderView.swift @@ -1,6 +1,11 @@ import UIKit import Shared +struct Member { + let title: String + let photo: Data? +} + final class GroupHeaderView: UIView { let titleLabel = UILabel() let containerView = UIView() @@ -39,14 +44,14 @@ final class GroupHeaderView: UIView { required init?(coder: NSCoder) { nil } - func setup(title: String, memberList: [(String, Data?)]) { + func setup(title: String, memberList: [Member]) { titleLabel.text = title - for member in memberList { + memberList.forEach { let avatarView = AvatarView() avatarView.layer.borderWidth = 3 avatarView.layer.borderColor = UIColor.white.cgColor - avatarView.setupProfile(title: member.0, image: member.1, size: .small) + avatarView.setupProfile(title: $0.title, image: $0.photo, size: .small) avatarView.snp.makeConstraints { $0.width.height.equalTo(25.0) } stackView.addArrangedSubview(avatarView) } diff --git a/Sources/ChatListFeature/Controller/ChatListController.swift b/Sources/ChatListFeature/Controller/ChatListController.swift index d2d15a400f402ec532ee97a2407ccf88415c1c93..bddccd284ce09ecb2b9e479044aa8fb41eb5e757 100644 --- a/Sources/ChatListFeature/Controller/ChatListController.swift +++ b/Sources/ChatListFeature/Controller/ChatListController.swift @@ -3,6 +3,7 @@ import Theme import Models import Shared import Combine +import XXModels import MenuFeature import DependencyInjection @@ -115,7 +116,8 @@ public final class ChatListController: UIViewController { collectionView: screenView.listContainerView.collectionView ) { collectionView, indexPath, contact in let cell: ChatListRecentContactCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - cell.setup(title: contact.nickname ?? contact.username, image: contact.photo) + let title = (contact.nickname ?? contact.username) ?? "" + cell.setup(title: title, image: contact.photo) return cell } diff --git a/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift b/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift index c1abc94ac78f3f3e95f1058a2e8ede9021d11616..46e9c20bed9b21b96d883f0ffc07db81caad0de9 100644 --- a/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift +++ b/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift @@ -32,49 +32,41 @@ final class ChatSearchTableController: UITableViewController { ) { table, indexPath, item in let cell = table.dequeueReusableCell(forIndexPath: indexPath, ofType: ChatListCell.self) switch item { - case .chat(let subitem): - if case .contact(let info) = subitem { - cell.setupContact( - name: info.contact.nickname ?? info.contact.username, - image: info.contact.photo, - date: Date.fromTimestamp(info.lastMessage!.timestamp), - hasUnread: info.lastMessage!.unread, - preview: info.lastMessage!.payload.text + case .chat(let info): + switch info { + case .group(let group): + cell.setupGroup( + name: group.name, + date: group.createdAt, + preview: nil, + unreadCount: 0 ) - } - - if case .group(let info) = subitem { - let date: Date = { - guard let lastMessage = info.lastMessage else { - return info.group.createdAt - } - - return Date.fromTimestamp(lastMessage.timestamp) - }() - - let hasUnread: Bool = { - guard let lastMessage = info.lastMessage else { - return false - } - - return lastMessage.unread - }() + case .groupChat(let groupChatInfo): cell.setupGroup( - name: info.group.name, - date: date, - preview: info.lastMessage?.payload.text, - hasUnread: hasUnread + name: groupChatInfo.group.name, + date: groupChatInfo.lastMessage.date, + preview: groupChatInfo.lastMessage.text, + unreadCount: groupChatInfo.unreadCount + ) + + case .contactChat(let contactChatInfo): + cell.setupContact( + name: (contactChatInfo.contact.nickname ?? contactChatInfo.contact.username) ?? "", + image: contactChatInfo.contact.photo, + date: contactChatInfo.lastMessage.date, + unreadCount: contactChatInfo.unreadCount, + preview: contactChatInfo.lastMessage.text ) } case .connection(let contact): cell.setupContact( - name: contact.nickname ?? contact.username, + name: (contact.nickname ?? contact.username) ?? "", image: contact.photo, date: nil, - hasUnread: false, - preview: contact.username + unreadCount: 0, + preview: contact.username ?? "" ) } @@ -112,14 +104,23 @@ extension ChatSearchTableController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if let item = tableDataSource?.itemIdentifier(for: indexPath) { switch item { - case .chat(let chat): - switch chat { - case .contact(let info): - guard info.contact.status == .friend else { return } + case .chat(let chatInfo): + switch chatInfo { + case .group(let group): + if let groupInfo = viewModel.groupInfo(from: group) { + coordinator.toGroupChat(with: groupInfo, from: self) + } + + case .groupChat(let info): + if let groupInfo = viewModel.groupInfo(from: info.group) { + coordinator.toGroupChat(with: groupInfo, from: self) + } + + case .contactChat(let info): + guard info.contact.authStatus == .friend else { return } coordinator.toSingleChat(with: info.contact, from: self) - case .group(let info): - coordinator.toGroupChat(with: info, from: self) } + case .connection(let contact): coordinator.toContact(contact, from: self) } diff --git a/Sources/ChatListFeature/Controller/ChatListTableController.swift b/Sources/ChatListFeature/Controller/ChatListTableController.swift index 17b744113f0ecc43eb229f62cd5f4ddd5d8da385..8527195eee326146909a865a6768e553c10a5e3d 100644 --- a/Sources/ChatListFeature/Controller/ChatListTableController.swift +++ b/Sources/ChatListFeature/Controller/ChatListTableController.swift @@ -2,14 +2,19 @@ import UIKit import Shared import Models import Combine +import XXModels import DifferenceKit import DrawerFeature import DependencyInjection +extension ChatInfo: Differentiable { + public var differenceIdentifier: ChatInfo.ID { id } +} + final class ChatListTableController: UITableViewController { @Dependency private var coordinator: ChatListCoordinating - private var rows = [Chat]() + private var rows = [ChatInfo]() private let viewModel: ChatListViewModel private let cellHeight: CGFloat = 83.0 private var cancellables = Set<AnyCancellable>() @@ -89,11 +94,19 @@ extension ChatListTableController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch rows[indexPath.row] { - case .contact(let info): - guard info.contact.status == .friend else { return } + case .group(let group): + if let groupInfo = viewModel.groupInfo(from: group) { + coordinator.toGroupChat(with: groupInfo, from: self) + } + + case .groupChat(let info): + if let groupInfo = viewModel.groupInfo(from: info.group) { + coordinator.toGroupChat(with: groupInfo, from: self) + } + + case .contactChat(let info): + guard info.contact.authStatus == .friend else { return } coordinator.toSingleChat(with: info.contact, from: self) - case .group(let info): - coordinator.toGroupChat(with: info, from: self) } } @@ -104,62 +117,60 @@ extension ChatListTableController { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: ChatListCell.self) - if case .contact(let info) = rows[indexPath.row] { - cell.setupContact( - name: info.contact.nickname ?? info.contact.username, - image: info.contact.photo, - date: Date.fromTimestamp(info.lastMessage!.timestamp), - hasUnread: info.lastMessage!.unread, - preview: info.lastMessage!.payload.text + switch rows[indexPath.row] { + case .group(let group): + cell.setupGroup( + name: group.name, + date: group.createdAt, + preview: nil, + unreadCount: 0 ) - } - - if case .group(let info) = rows[indexPath.row] { - let date: Date = { - guard let lastMessage = info.lastMessage else { - return info.group.createdAt - } - - return Date.fromTimestamp(lastMessage.timestamp) - }() - - let hasUnread: Bool = { - guard let lastMessage = info.lastMessage else { - return false - } - - return lastMessage.unread - }() + case .groupChat(let info): cell.setupGroup( name: info.group.name, - date: date, - preview: info.lastMessage?.payload.text, - hasUnread: hasUnread + date: info.lastMessage.date, + preview: info.lastMessage.text, + unreadCount: info.unreadCount + ) + + case .contactChat(let info): + cell.setupContact( + name: (info.contact.nickname ?? info.contact.username) ?? "", + image: info.contact.photo, + date: info.lastMessage.date, + unreadCount: info.unreadCount, + preview: info.lastMessage.text ) } return cell } - private func didRequestDeletionOf(_ item: Chat) { + private func didRequestDeletionOf(_ item: ChatInfo) { let title: String let subtitle: String let actionTitle: String let actionClosure: () -> Void switch item { - case .group(let info): + case .group(let group): title = Localized.ChatList.DeleteGroup.title subtitle = Localized.ChatList.DeleteGroup.subtitle actionTitle = Localized.ChatList.DeleteGroup.action - actionClosure = { [weak viewModel] in viewModel?.leave(info.group) } + actionClosure = { [weak viewModel] in viewModel?.leave(group) } - case .contact(let info): + case .contactChat(let info): title = Localized.ChatList.Delete.title subtitle = Localized.ChatList.Delete.subtitle actionTitle = Localized.ChatList.Delete.delete actionClosure = { [weak viewModel] in viewModel?.clear(info.contact) } + + case .groupChat(let info): + title = Localized.ChatList.DeleteGroup.title + subtitle = Localized.ChatList.DeleteGroup.subtitle + actionTitle = Localized.ChatList.DeleteGroup.action + actionClosure = { [weak viewModel] in viewModel?.leave(info.group) } } let actionButton = DrawerCapsuleButton(model: .init(title: actionTitle, style: .red)) diff --git a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift index 42b7dda644aa04aa7f928ad8006d59715d442b4b..bb412859b16fe99591c8b658fc4b8798fe27fb00 100644 --- a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift +++ b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Models +import XXModels import MenuFeature import ChatFeature import Presentation @@ -16,7 +17,7 @@ public protocol ChatListCoordinating { func toContact(_: Contact, from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) - func toGroupChat(with: GroupChatInfo, from: UIViewController) + func toGroupChat(with: GroupInfo, from: UIViewController) } public struct ChatListCoordinator: ChatListCoordinating { @@ -31,7 +32,7 @@ public struct ChatListCoordinator: ChatListCoordinating { var contactsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController var singleChatFactory: (Contact) -> UIViewController - var groupChatFactory: (GroupChatInfo) -> UIViewController + var groupChatFactory: (GroupInfo) -> UIViewController var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController public init( @@ -41,7 +42,7 @@ public struct ChatListCoordinator: ChatListCoordinating { contactsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, - groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, + groupChatFactory: @escaping (GroupInfo) -> UIViewController, sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController ) { self.scanFactory = scanFactory @@ -81,7 +82,7 @@ public extension ChatListCoordinator { pushPresenter.present(screen, from: parent) } - func toGroupChat(with group: GroupChatInfo, from parent: UIViewController) { + func toGroupChat(with group: GroupInfo, from parent: UIViewController) { let screen = groupChatFactory(group) pushPresenter.present(screen, from: parent) } diff --git a/Sources/ChatListFeature/Models/Chat.swift b/Sources/ChatListFeature/Models/Chat.swift deleted file mode 100644 index 3e159479aeaade0a5b1355d19eccf627f5d5f3b7..0000000000000000000000000000000000000000 --- a/Sources/ChatListFeature/Models/Chat.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Models -import Foundation -import DifferenceKit - -enum Chat: Equatable, Differentiable, Hashable { - case group(GroupChatInfo) - case contact(SingleChatInfo) - - var differenceIdentifier: Data { - switch self { - case .contact(let info): - return info.contact.userId - case .group(let info): - return info.group.groupId - } - } - - var orderingDate: Date { - switch self { - case .group(let info): - if let lastMessage = info.lastMessage { - return Date.fromTimestamp(lastMessage.timestamp) - } else { - return info.group.createdAt - } - case .contact(let info): - guard let lastMessage = info.lastMessage else { - fatalError("Should have an E2E chat without a last message") - } - - return Date.fromTimestamp(lastMessage.timestamp) - } - } -} diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift index ceb096f383a164fd6ead74e9819089b4e45a5807..98febff09e352ff1e0ff495c032f1e7d1ec1613a 100644 --- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift +++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift @@ -3,6 +3,7 @@ import UIKit import Shared import Models import Combine +import XXModels import Defaults import Integration import DependencyInjection @@ -13,7 +14,7 @@ enum SearchSection { } enum SearchItem: Equatable, Hashable { - case chat(Chat) + case chat(ChatInfo) case connection(Contact) } @@ -27,7 +28,7 @@ final class ChatListViewModel { session.isOnline } - var chatsPublisher: AnyPublisher<[Chat], Never> { + var chatsPublisher: AnyPublisher<[ChatInfo], Never> { chatsSubject.eraseToAnyPublisher() } @@ -36,7 +37,9 @@ final class ChatListViewModel { } var recentsPublisher: AnyPublisher<RecentsSnapshot, Never> { - session.contacts(.isRecent).map { + session.dbManager.fetchContactsPublisher(.init(isRecent: true)) + .assertNoFailure() + .map { let section = SectionId() var snapshot = RecentsSnapshot() snapshot.appendSections([section]) @@ -47,7 +50,7 @@ final class ChatListViewModel { var searchPublisher: AnyPublisher<SearchSnapshot, Never> { Publishers.CombineLatest3( - session.contacts(.all), + session.dbManager.fetchContactsPublisher(.init()).assertNoFailure(), chatsPublisher, searchSubject .removeDuplicates() @@ -56,23 +59,27 @@ final class ChatListViewModel { ) .map { (contacts, chats, query) in let connectionItems = contacts.filter { - let username = $0.username.lowercased().contains(query.lowercased()) + let username = $0.username?.lowercased().contains(query.lowercased()) ?? false let nickname = $0.nickname?.lowercased().contains(query.lowercased()) ?? false return username || nickname }.map(SearchItem.connection) let chatItems = chats.filter { switch $0 { - case .contact(let info): - let username = info.contact.username.lowercased().contains(query.lowercased()) - let nickname = info.contact.nickname?.lowercased().contains(query.lowercased()) ?? false - let lastMessage = info.lastMessage?.payload.text.lowercased().contains(query.lowercased()) ?? false - return username || nickname || lastMessage + case .group(let group): + return group.name.lowercased().contains(query.lowercased()) - case .group(let info): + case .groupChat(let info): let name = info.group.name.lowercased().contains(query.lowercased()) - let last = info.lastMessage?.payload.text.lowercased().contains(query.lowercased()) ?? false + let last = info.lastMessage.text.lowercased().contains(query.lowercased()) return name || last + + case .contactChat(let info): + let username = info.contact.username?.lowercased().contains(query.lowercased()) ?? false + let nickname = info.contact.nickname?.lowercased().contains(query.lowercased()) ?? false + let lastMessage = info.lastMessage.text.lowercased().contains(query.lowercased()) + return username || nickname || lastMessage + } }.map(SearchItem.chat) @@ -93,9 +100,18 @@ final class ChatListViewModel { } var badgeCountPublisher: AnyPublisher<Int, Never> { - Publishers.CombineLatest( - session.contacts(.received), - session.groups(.pending) + let groupQuery = Group.Query(authStatus: [.pending]) + let contactsQuery = Contact.Query(authStatus: [ + .verified, + .confirming, + .confirmationFailed, + .verificationFailed, + .verificationInProgress + ]) + + return Publishers.CombineLatest( + session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(), + session.dbManager.fetchGroupsPublisher(groupQuery).assertNoFailure() ) .map { $0.0.count + $0.1.count } .eraseToAnyPublisher() @@ -103,20 +119,27 @@ final class ChatListViewModel { private var cancellables = Set<AnyCancellable>() private let searchSubject = CurrentValueSubject<String, Never>("") - private let chatsSubject = CurrentValueSubject<[Chat], Never>([]) + private let chatsSubject = CurrentValueSubject<[ChatInfo], Never>([]) private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) init() { - Publishers.CombineLatest( - session.singleChats(.all), - session.groupChats(.accepted) - ).map { - let groups = $0.1.map(Chat.group) - let chats = $0.0.map(Chat.contact) - return (chats + groups).sorted { $0.orderingDate > $1.orderingDate } - } - .sink { [unowned self] in chatsSubject.send($0) } - .store(in: &cancellables) + session.dbManager.fetchChatInfosPublisher( + ChatInfo.Query( + contactChatInfoQuery: .init( + userId: session.myId, + authStatus: [.friend] + ), + groupChatInfoQuery: GroupChatInfo.Query( + authStatus: [.participating] + ), + groupQuery: Group.Query( + withMessages: false, + authStatus: [.participating] + ) + )) + .assertNoFailure() + .sink { [unowned self] in chatsSubject.send($0) } + .store(in: &cancellables) } func updateSearch(query: String) { @@ -128,7 +151,7 @@ final class ChatListViewModel { do { try session.leave(group: group) - session.deleteAll(from: group) + try session.dbManager.deleteMessages(.init(chat: .group(group.id))) hudSubject.send(.none) } catch { hudSubject.send(.error(.init(with: error))) @@ -136,6 +159,15 @@ final class ChatListViewModel { } func clear(_ contact: Contact) { - session.deleteAll(from: contact) + _ = try? session.dbManager.deleteMessages(.init(chat: .direct(session.myId, contact.id))) + } + + func groupInfo(from group: Group) -> GroupInfo? { + let query = GroupInfo.Query(groupId: group.id) + guard let info = try? session.dbManager.fetchGroupInfos(query).first else { + return nil + } + + return info } } diff --git a/Sources/ChatListFeature/Views/ChatListCell.swift b/Sources/ChatListFeature/Views/ChatListCell.swift index 8eea62d2278b630efeb6afb1d12fad725cd23c18..bb455395abbc36921e364b27a5ca07e573726ea3 100644 --- a/Sources/ChatListFeature/Views/ChatListCell.swift +++ b/Sources/ChatListFeature/Views/ChatListCell.swift @@ -4,6 +4,7 @@ import Shared final class ChatListCell: UITableViewCell { private let titleLabel = UILabel() private let unreadView = UIView() + private let unreadCountLabel = UILabel() private let previewLabel = UILabel() private let dateLabel = UILabel() private let avatarView = AvatarView() @@ -31,10 +32,11 @@ final class ChatListCell: UITableViewCell { backgroundColor = Asset.neutralWhite.color dateLabel.textColor = Asset.neutralWeak.color titleLabel.textColor = Asset.neutralActive.color + unreadCountLabel.textColor = Asset.neutralWhite.color dateLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) titleLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) - + unreadCountLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) timer = Timer.scheduledTimer(withTimeInterval: 59, repeats: true) { [weak self] _ in self?.updateTimeAgoLabel() @@ -47,6 +49,7 @@ final class ChatListCell: UITableViewCell { contentView.addSubview(avatarView) contentView.addSubview(previewLabel) contentView.addSubview(dateLabel) + unreadView.addSubview(unreadCountLabel) avatarView.snp.makeConstraints { $0.top.equalToSuperview().offset(14) @@ -54,6 +57,10 @@ final class ChatListCell: UITableViewCell { $0.width.height.equalTo(48) } + unreadCountLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + titleLabel.snp.makeConstraints { $0.top.equalToSuperview().offset(10) $0.left.equalTo(avatarView.snp.right).offset(16) @@ -85,6 +92,7 @@ final class ChatListCell: UITableViewCell { super.prepareForReuse() lastDate = nil titleLabel.text = nil + unreadCountLabel.text = nil previewLabel.attributedText = nil avatarView.prepareForReuse() } @@ -99,13 +107,14 @@ final class ChatListCell: UITableViewCell { name: String, image: Data?, date: Date?, - hasUnread: Bool, + unreadCount: Int, preview: String ) { titleLabel.text = name setPreview(string: preview) avatarView.setupProfile(title: name, image: image, size: .large) - unreadView.backgroundColor = hasUnread ? Asset.brandPrimary.color : .clear + unreadCountLabel.text = "\(unreadCount)" + unreadView.backgroundColor = unreadCount > 0 ? Asset.brandPrimary.color : .clear if let date = date { lastDate = date @@ -118,13 +127,14 @@ final class ChatListCell: UITableViewCell { name: String, date: Date, preview: String?, - hasUnread: Bool + unreadCount: Int ) { lastDate = date titleLabel.text = name setPreview(string: preview) avatarView.setupGroup(size: .large) - unreadView.backgroundColor = hasUnread ? Asset.brandPrimary.color : .clear + unreadCountLabel.text = "\(unreadCount)" + unreadView.backgroundColor = unreadCount > 0 ? Asset.brandPrimary.color : .clear } private func setPreview(string: String?) { diff --git a/Sources/ContactFeature/Controllers/ContactController.swift b/Sources/ContactFeature/Controllers/ContactController.swift index f7c33bb8ee34b21cd3582f019cd8ac54b90979b1..1ef8e96ffba5f58ec2ae2ac6e03adc701e1b3ffb 100644 --- a/Sources/ContactFeature/Controllers/ContactController.swift +++ b/Sources/ContactFeature/Controllers/ContactController.swift @@ -1,10 +1,11 @@ import HUD -import DrawerFeature import UIKit import Theme import Shared import Models import Combine +import XXModels +import DrawerFeature import DependencyInjection import ScrollViewController @@ -58,7 +59,7 @@ public final class ContactController: UIViewController { ) } - screenView.set(status: viewModel.contact.status) + screenView.set(status: viewModel.contact.authStatus) } private func setupNavigationBar() { @@ -168,7 +169,7 @@ public final class ContactController: UIViewController { .sink { [unowned self] in coordinator.toNickname( from: self, - prefilled: viewModel.contact.nickname ?? viewModel.contact.username, + prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", viewModel.didTapRequest(with:) ) }.store(in: &cancellables) @@ -180,7 +181,7 @@ public final class ContactController: UIViewController { .sink { [unowned self] in coordinator.toNickname( from: self, - prefilled: viewModel.contact.nickname ?? viewModel.contact.username, + prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", viewModel.didTapAccept(_:) ) }.store(in: &cancellables) @@ -242,7 +243,7 @@ public final class ContactController: UIViewController { .sink { [unowned self] in coordinator.toNickname( from: self, - prefilled: viewModel.contact.nickname ?? viewModel.contact.username, + prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", viewModel.didUpdateNickname(_:) ) } diff --git a/Sources/ContactFeature/Coordinator/ContactCoordinator.swift b/Sources/ContactFeature/Coordinator/ContactCoordinator.swift index 2578bf8cb09a4a58f866dabb4135db243e9c43d4..eea7f1ca94bbcace7a637abc5a1d094d53fff74b 100644 --- a/Sources/ContactFeature/Coordinator/ContactCoordinator.swift +++ b/Sources/ContactFeature/Coordinator/ContactCoordinator.swift @@ -1,6 +1,7 @@ import UIKit import Models import Shared +import XXModels import ChatFeature import Presentation diff --git a/Sources/ContactFeature/ViewModels/ContactViewModel.swift b/Sources/ContactFeature/ViewModels/ContactViewModel.swift index fd456e3f46d14f1f352a364ab35b1c4295e88635..67005d4b9d01cbc493ead72d8f6b89e42e64535e 100644 --- a/Sources/ContactFeature/ViewModels/ContactViewModel.swift +++ b/Sources/ContactFeature/ViewModels/ContactViewModel.swift @@ -2,6 +2,7 @@ import HUD import UIKit import Models import Combine +import XXModels import Integration import CombineSchedulers import DependencyInjection @@ -38,8 +39,8 @@ final class ContactViewModel { self.contact = contact do { - let email = try session.extract(fact: .email, from: contact.marshaled) - let phone = try session.extract(fact: .phone, from: contact.marshaled) + let email = try session.extract(fact: .email, from: contact.marshaled!) + let phone = try session.extract(fact: .phone, from: contact.marshaled!) stateRelay.value = .init( title: contact.nickname ?? contact.username, @@ -57,7 +58,7 @@ final class ContactViewModel { func didChoosePhoto(_ photo: UIImage) { stateRelay.value.photo = photo contact.photo = photo.jpegData(compressionQuality: 0.0) - session.update(contact) + _ = try? session.dbManager.saveContact(contact) } func didTapDelete() { @@ -73,18 +74,18 @@ final class ContactViewModel { } func didTapReject() { - session.delete(contact, isRequest: true) + try? session.deleteContact(contact) popRelay.send() } func didTapClear() { - session.deleteAll(from: contact) + _ = try? session.dbManager.deleteMessages(.init(chat: .direct(session.myId, contact.id))) } func didUpdateNickname(_ string: String) { contact.nickname = string.isEmpty ? nil : string stateRelay.value.title = string.isEmpty ? contact.username : string - session.update(contact) + _ = try? session.dbManager.saveContact(contact) stateRelay.value.nickname = contact.nickname } diff --git a/Sources/ContactFeature/Views/ContactInProgressView.swift b/Sources/ContactFeature/Views/ContactInProgressView.swift index b4cbfa038f6abfa9a10a43598566d7dcc65170fc..165600f9deb908c47de78f7e4b71e9213d3d847e 100644 --- a/Sources/ContactFeature/Views/ContactInProgressView.swift +++ b/Sources/ContactFeature/Views/ContactInProgressView.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Models +import XXModels final class ContactAlmostView: UIView { // MARK: UI @@ -19,7 +20,7 @@ final class ContactAlmostView: UIView { // MARK: Public - func set(status: Contact.Status) { + func set(status: Contact.AuthStatus) { switch status { case .requestFailed, .confirmationFailed: feedback.set( diff --git a/Sources/ContactFeature/Views/ContactView.swift b/Sources/ContactFeature/Views/ContactView.swift index 8111a103c021d7c78bb374e54a10bab2d7cbd83c..e5fb03927caf3de9018a382a004b7c89c8a69e99 100644 --- a/Sources/ContactFeature/Views/ContactView.swift +++ b/Sources/ContactFeature/Views/ContactView.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Models +import XXModels final class ContactView: UIView { let container = UIView() @@ -38,7 +39,7 @@ final class ContactView: UIView { required init?(coder: NSCoder) { nil } - func set(status: Contact.Status) { + func set(status: Contact.AuthStatus) { let contentView: UIView switch status { diff --git a/Sources/ContactListFeature/Controllers/ContactListTableController.swift b/Sources/ContactListFeature/Controllers/ContactListTableController.swift index 5ae07f2fa985d5a35ec2d296a63096ab4d3f50e9..f940366ae08cfe77f9cd54e5d1008a55b7149bc6 100644 --- a/Sources/ContactListFeature/Controllers/ContactListTableController.swift +++ b/Sources/ContactListFeature/Controllers/ContactListTableController.swift @@ -1,7 +1,8 @@ import UIKit import Shared -import Combine import Models +import Combine +import XXModels final class ContactListTableController: UITableViewController { private var collation = UILocalizedIndexedCollation.current() @@ -46,8 +47,9 @@ final class ContactListTableController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: SmallAvatarAndTitleCell = tableView.dequeueReusableCell(forIndexPath: indexPath) let contact = sections[indexPath.section][indexPath.row] - cell.titleLabel.text = contact.nickname ?? contact.username - cell.avatarView.setupProfile(title: contact.nickname ?? contact.username, image: contact.photo, size: .medium) + let name = (contact.nickname ?? contact.username) ?? "Fetching username..." + cell.titleLabel.text = name + cell.avatarView.setupProfile(title: name, image: contact.photo, size: .medium) return cell } diff --git a/Sources/ContactListFeature/Controllers/CreateGroupController.swift b/Sources/ContactListFeature/Controllers/CreateGroupController.swift index f307813c7f8e3dd959294979f9dfd26183dbccf2..9e9d039dd5e5fe2dd4adfa179fc736ab51fa6c4b 100644 --- a/Sources/ContactListFeature/Controllers/CreateGroupController.swift +++ b/Sources/ContactListFeature/Controllers/CreateGroupController.swift @@ -3,6 +3,7 @@ import UIKit import Models import Shared import Combine +import XXModels import DependencyInjection public final class CreateGroupController: UIViewController { @@ -73,7 +74,8 @@ public final class CreateGroupController: UIViewController { ) { [weak viewModel] collectionView, indexPath, contact in let cell: CreateGroupCollectionCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - cell.setup(title: contact.nickname ?? contact.username, image: contact.photo) + let title = (contact.nickname ?? contact.username) ?? "" + cell.setup(title: title, image: contact.photo) cell.didTapRemove = { viewModel?.didSelect(contact: contact) } return cell @@ -83,8 +85,9 @@ public final class CreateGroupController: UIViewController { tableView: screenView.tableView ) { [weak self] tableView, indexPath, contact in let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SmallAvatarAndTitleCell.self) - cell.titleLabel.text = contact.nickname ?? contact.username - cell.avatarView.setupProfile(title: contact.nickname ?? contact.username, image: contact.photo, size: .medium) + let title = (contact.nickname ?? contact.username) ?? "" + cell.titleLabel.text = title + cell.avatarView.setupProfile(title: title, image: contact.photo, size: .medium) if let selectedElements = self?.selectedElements, selectedElements.contains(contact) { tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) diff --git a/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift b/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift index 6afe76e1aae847c8080d1b5c80795b5884a2c40d..baf38264a85467038888c2b37e618598a7190acd 100644 --- a/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift +++ b/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Models +import XXModels import MenuFeature import ChatFeature import Presentation @@ -15,7 +16,7 @@ public protocol ContactListCoordinating { func toSideMenu(from: UIViewController) func toContact(_: Contact, from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) - func toGroupChat(with: GroupChatInfo, from: UIViewController) + func toGroupChat(with: GroupInfo, from: UIViewController) func toGroupDrawer(with: Int, from: UIViewController, _: @escaping (String, String?) -> Void) } @@ -31,7 +32,7 @@ public struct ContactListCoordinator: ContactListCoordinating { var requestsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController var singleChatFactory: (Contact) -> UIViewController - var groupChatFactory: (GroupChatInfo) -> UIViewController + var groupChatFactory: (GroupInfo) -> UIViewController var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController var groupDrawerFactory: (Int, @escaping (String, String?) -> Void) -> UIViewController @@ -42,7 +43,7 @@ public struct ContactListCoordinator: ContactListCoordinating { requestsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, - groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, + groupChatFactory: @escaping (GroupInfo) -> UIViewController, sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController, groupDrawerFactory: @escaping (Int, @escaping (String, String?) -> Void) -> UIViewController ) { @@ -101,7 +102,7 @@ public extension ContactListCoordinator { pushPresenter.present(screen, from: parent) } - func toGroupChat(with info: GroupChatInfo, from parent: UIViewController) { + func toGroupChat(with info: GroupInfo, from parent: UIViewController) { let screen = groupChatFactory(info) pushPresenter.present(screen, from: parent) } diff --git a/Sources/ContactListFeature/Helpers/IndexedListCollator.swift b/Sources/ContactListFeature/Helpers/IndexedListCollator.swift new file mode 100644 index 0000000000000000000000000000000000000000..249af6fad35653a205a8d485dbab088cc8ad9578 --- /dev/null +++ b/Sources/ContactListFeature/Helpers/IndexedListCollator.swift @@ -0,0 +1,53 @@ +import UIKit +import XXModels + +public protocol IndexableItem { + var indexedOn: NSString { get } +} + +class IndexedListCollator<Item: IndexableItem> { + private final class CollationWrapper: NSObject { + let value: Any + @objc let indexedOn: NSString + + init(value: Any, indexedOn: NSString) { + self.value = value + self.indexedOn = indexedOn + } + + func unwrappedValue<UnwrappedType>() -> UnwrappedType { + return value as! UnwrappedType + } + } + + public init() {} + + public func sectioned(items: [Item]) -> (sections: [[Item]], collation: UILocalizedIndexedCollation) { + let collation = UILocalizedIndexedCollation.current() + let selector = #selector(getter: CollationWrapper.indexedOn) + + let wrappedItems = items.map { item in + CollationWrapper(value: item, indexedOn: item.indexedOn) + } + + let sortedObjects = collation.sortedArray(from: wrappedItems, collationStringSelector: selector) as! [CollationWrapper] + + var sections = collation.sectionIndexTitles.map { _ in [Item]() } + sortedObjects.forEach { item in + let sectionNumber = collation.section(for: item, collationStringSelector: selector) + sections[sectionNumber].append(item.unwrappedValue()) + } + + return (sections: sections.filter { !$0.isEmpty }, collation: collation) + } +} + +extension Contact: IndexableItem { + public var indexedOn: NSString { + guard let nickname = nickname else { + return "\(username!.first!)" as NSString + } + + return "\(nickname.first!)" as NSString + } +} diff --git a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift index c1d5d1f83114feaef55a898c9bce85623a20f156..0467446065b81d8f52a0f9517948160e623de1e2 100644 --- a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift +++ b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift @@ -1,5 +1,6 @@ import Models import Combine +import XXModels import Integration import DependencyInjection @@ -7,27 +8,27 @@ final class ContactListViewModel { @Dependency private var session: SessionType var contacts: AnyPublisher<[Contact], Never> { - session.contacts(.friends).eraseToAnyPublisher() + session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend])) + .assertNoFailure() + .map { $0.filter { $0.id != self.session.myId }} + .eraseToAnyPublisher() } var requestCount: AnyPublisher<Int, Never> { - Publishers.CombineLatest( - session.contacts(.received), - session.groups(.pending) - ).map { (contacts, groups) in - let contactRequests = contacts.filter { - $0.status == .verified || - $0.status == .confirming || - $0.status == .confirmationFailed || - $0.status == .verificationFailed || - $0.status == .verificationInProgress - } + let groupQuery = Group.Query(authStatus: [.pending]) + let contactsQuery = Contact.Query(authStatus: [ + .verified, + .confirming, + .confirmationFailed, + .verificationFailed, + .verificationInProgress + ]) - let groupRequests = groups.filter { - $0.status == .pending - } - - return contactRequests.count + groupRequests.count - }.eraseToAnyPublisher() + return Publishers.CombineLatest( + session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(), + session.dbManager.fetchGroupsPublisher(groupQuery).assertNoFailure() + ) + .map { $0.0.count + $0.1.count } + .eraseToAnyPublisher() } } diff --git a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift index 403e8361b163faa018d16e2702f349428df38838..67e70645ed6c0d00f02862bdb02f199d3d735d9d 100644 --- a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift +++ b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift @@ -1,10 +1,11 @@ import HUD -import Combine +import UIKit import Models +import Combine +import XXModels +import Defaults import Integration import DependencyInjection -import Defaults -import UIKit final class CreateGroupViewModel { @KeyObject(.username, defaultValue: "") var username: String @@ -27,13 +28,13 @@ final class CreateGroupViewModel { hudRelay.eraseToAnyPublisher() } - var info: AnyPublisher<GroupChatInfo, Never> { + var info: AnyPublisher<GroupInfo, Never> { infoRelay.eraseToAnyPublisher() } private var allContacts = [Contact]() private var cancellables = Set<AnyCancellable>() - private let infoRelay = PassthroughSubject<GroupChatInfo, Never>() + private let infoRelay = PassthroughSubject<GroupInfo, Never>() private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) private let contactsRelay = CurrentValueSubject<[Contact], Never>([]) private let selectedContactsRelay = CurrentValueSubject<[Contact], Never>([]) @@ -41,8 +42,10 @@ final class CreateGroupViewModel { // MARK: Lifecycle init() { - session.contacts(.friends) - .map { $0.sorted(by: { $0.username < $1.username })} + session.dbManager.fetchContactsPublisher(.init(authStatus: [.friend])) + .assertNoFailure() + .map { $0.filter { $0.id != self.session.myId }} + .map { $0.sorted(by: { $0.username! < $1.username! })} .sink { [unowned self] in allContacts = $0 contactsRelay.send($0) @@ -65,7 +68,11 @@ final class CreateGroupViewModel { return } - contactsRelay.send(allContacts.filter { $0.username.contains(text.lowercased()) }) + contactsRelay.send( + allContacts.filter { + ($0.username ?? "").contains(text.lowercased()) + } + ) } func create(name: String, welcome: String?, members: [Contact]) { @@ -77,8 +84,8 @@ final class CreateGroupViewModel { self.hudRelay.send(.none) switch $0 { - case .success((let group, let members)): - self.infoRelay.send(.init(group: group, members: members)) + case .success(let info): + self.infoRelay.send(info) case .failure(let error): self.hudRelay.send(.error(.init(with: error))) } diff --git a/Sources/Database/DB+Contact.swift b/Sources/Database/DB+Contact.swift deleted file mode 100644 index 4207fea75d83ea0ef6e56ae361cb5a172c8773c7..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+Contact.swift +++ /dev/null @@ -1,57 +0,0 @@ -import GRDB -import Models - -extension Contact: Persistable { - public enum Column: String, ColumnExpression { - case id - case photo - case email - case phone - case userId - case status - case username - case isRecent - case nickname - case marshaled - case createdAt - } - - public mutating func didInsert(with rowID: Int64, for column: String?) { - id = rowID - } - - public static func query(_ request: Request) -> QueryInterfaceRequest<Contact> { - switch request { - case .all: - return Contact.all() - case .isRecent: - return Contact - .filter(Column.isRecent == true) - .order(Column.createdAt.desc) - case .verificationInProgress: - return Contact.filter(Column.status == Contact.Status.verificationInProgress.rawValue) - case .failed: - return Contact.filter( - Column.status == Contact.Status.requestFailed.rawValue || - Column.status == Contact.Status.confirmationFailed.rawValue - ) - case .requested: - return Contact.filter( - Column.status == Contact.Status.requested.rawValue || - Column.status == Contact.Status.requesting.rawValue - ) - case .received: - return Contact.filter( - Column.status == Contact.Status.hidden.rawValue || - Column.status == Contact.Status.verified.rawValue || - Column.status == Contact.Status.verificationFailed.rawValue || - Column.status == Contact.Status.verificationInProgress.rawValue - ) - - case .friends: return Contact.filter(Column.status == Contact.Status.friend.rawValue) - case let .withUserId(data): return Contact.filter(Column.userId == data) - case let .withUserIds(ids): return Contact.filter(ids.contains(Contact.Column.userId)) - case let .withUsername(username): return Contact.filter(Column.username.like("\(username)%")) - } - } -} diff --git a/Sources/Database/DB+FileTransfer.swift b/Sources/Database/DB+FileTransfer.swift deleted file mode 100644 index 617d8ce34c0b60f3a54c4e9f18e68cfa0ba1dc67..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+FileTransfer.swift +++ /dev/null @@ -1,26 +0,0 @@ -import GRDB -import Models - -extension FileTransfer: Persistable { - public enum Column: String, ColumnExpression { - case id - case tid - case contact - case fileName - case fileType - case isIncoming - } - - public mutating func didInsert(with rowID: Int64, for column: String?) { - id = rowID - } - - public static func query(_ request: Request) -> QueryInterfaceRequest<FileTransfer> { - switch request { - case .withTID(let transferId): - return FileTransfer.filter(Column.tid == transferId) - case .withContactId(let contactId): - return FileTransfer.filter(Column.contact == contactId) - } - } -} diff --git a/Sources/Database/DB+Group.swift b/Sources/Database/DB+Group.swift deleted file mode 100644 index dc78a33efae1c30fd484d7fd2c99e3e996134ec2..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+Group.swift +++ /dev/null @@ -1,35 +0,0 @@ -import GRDB -import Models - -extension Group: Persistable { - static let members = hasMany(GroupMember.self) - - public enum Column: String, ColumnExpression { - case id - case name - case leader - case groupId - case status - case serialize - case createdAt - case accepted // Deprecated - } - - public mutating func didInsert(with rowID: Int64, for column: String?) { - id = rowID - } - - public static func query(_ request: Request) -> QueryInterfaceRequest<Group> { - switch request { - case .withGroupId(let id): - return Group.filter(Column.groupId == id) - case .accepted: - return Group.filter(Column.status == Group.Status.participating.rawValue) - case .pending: - return Group.filter( - Column.status == Group.Status.pending.rawValue || - Column.status == Group.Status.hidden.rawValue - ) - } - } -} diff --git a/Sources/Database/DB+GroupChatInfo.swift b/Sources/Database/DB+GroupChatInfo.swift deleted file mode 100644 index 4be4dfad3fccda964b68873b5ddcf3cc4253aeed..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+GroupChatInfo.swift +++ /dev/null @@ -1,38 +0,0 @@ -import GRDB -import Models - -extension GroupChatInfo: Requestable { - public static func query(_ request: Request) -> QueryInterfaceRequest<GroupChatInfo> { - let lastMessageRequest = GroupMessage - .annotated(with: max(GroupMessage.Column.timestamp)) - .group(GroupMessage.Column.groupId) - - let lastMessageCTE = CommonTableExpression<GroupMessage>( - named: "lastMessage", - request: lastMessageRequest - ) - - let lastMessage = Group.association(to: lastMessageCTE) { group, lastMessage in - lastMessage[GroupMessage.Column.groupId] == group[Group.Column.groupId] - }.order(GroupMessage.Column.timestamp.desc) - - switch request { - case .fromGroup(let groupId): - return Group - .filter(Group.Column.status == Group.Status.participating.rawValue) - .filter(Group.Column.groupId == groupId) - .with(lastMessageCTE) - .including(optional: lastMessage) - .including(all: Group.members.forKey("members")) - .asRequest(of: Self.self) - - case .accepted: - return Group - .filter(Group.Column.status == Group.Status.participating.rawValue) - .with(lastMessageCTE) - .including(optional: lastMessage) - .including(all: Group.members.forKey("members")) - .asRequest(of: Self.self) - } - } -} diff --git a/Sources/Database/DB+GroupMember.swift b/Sources/Database/DB+GroupMember.swift deleted file mode 100644 index 2153704e4d14c37b9a4e122ca77299b76bd655a6..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+GroupMember.swift +++ /dev/null @@ -1,32 +0,0 @@ -import GRDB -import Models - -extension GroupMember: Persistable { - public enum Column: String, ColumnExpression { - case id - case photo - case status - case userId - case groupId - case username - } - - public mutating func didInsert(with rowID: Int64, for column: String?) { - id = rowID - } - - public static func query(_ request: Request) -> QueryInterfaceRequest<GroupMember> { - switch request { - case .all: - return GroupMember.all() - case let .withUserId(userId): - return GroupMember.filter(Column.userId == userId) - case .fromGroup(let groupId): - return GroupMember.filter(Column.groupId == groupId) - case .strangers: - return GroupMember.filter( - Column.status == GroupMember.Status.pendingUsername.rawValue - ) - } - } -} diff --git a/Sources/Database/DB+GroupMessage.swift b/Sources/Database/DB+GroupMessage.swift deleted file mode 100644 index 7ec6754d6fa0073ba6995b0a44145d1e4a16099e..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+GroupMessage.swift +++ /dev/null @@ -1,36 +0,0 @@ -import GRDB -import Models - -extension GroupMessage: Persistable { - public enum Column: String, ColumnExpression { - case id - case sender - case status - case unread - case payload - case groupId - case uniqueId - case roundURL - case timestamp - case roundId - } - - public mutating func didInsert(with rowID: Int64, for column: String?) { - id = rowID - } - - public static func query(_ request: Request) -> QueryInterfaceRequest<GroupMessage> { - switch request { - case let .withUniqueId(id): - return GroupMessage.filter(Column.uniqueId == id) - case let .id(id): - return GroupMessage.filter(Column.id == id) - case let .fromGroup(id): - return GroupMessage.filter(Column.groupId == id).order(Column.timestamp.asc) - case let .unreadsFromGroup(id): - return GroupMessage.filter(Column.groupId == id).filter(Column.unread == true) - case .sending: - return GroupMessage.filter(Column.status == GroupMessage.Status.sending.rawValue) - } - } -} diff --git a/Sources/Database/DB+Message.swift b/Sources/Database/DB+Message.swift deleted file mode 100644 index b5057ea99d870f7861df21f46dac9b47c92d245d..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+Message.swift +++ /dev/null @@ -1,46 +0,0 @@ -import GRDB -import Models - -extension Message: Persistable { - public enum Column: String, ColumnExpression { - case id - case report - case sender - case unread - case status - case payload - case roundURL - case receiver - case uniqueId - case timestamp - } - - public mutating func didInsert(with rowID: Int64, for column: String?) { - id = rowID - } - - public static func query(_ request: Request) -> QueryInterfaceRequest<Message> { - switch request { - case let .withUniqueId(id): - return Message.filter(Column.uniqueId == id) - case let .unreadsFromContactId(id): - return Message - .filter(Column.sender == id || Column.receiver == id) - .filter(Column.unread == true) - - case let .latestOnesFromContactIds(ids): - return Message - .annotated(with: Column.timestamp) - .filter(ids.contains(Column.sender) || ids.contains(Column.receiver)) - - case let .withId(id): - return Message.filter(Column.id == id) - case let .withContact(id): - return Message.filter(Column.sender == id || Column.receiver == id) - case .sending: - return Message.filter(Column.status == Message.Status.sending.rawValue) - case .sendingAttachment: - return Message.filter(Column.status == Message.Status.sendingAttachment.rawValue) - } - } -} diff --git a/Sources/Database/DB+SingleChatInfo.swift b/Sources/Database/DB+SingleChatInfo.swift deleted file mode 100644 index 16fbc4ff9299a8decc603559c09beebf1aa16f9b..0000000000000000000000000000000000000000 --- a/Sources/Database/DB+SingleChatInfo.swift +++ /dev/null @@ -1,27 +0,0 @@ -import GRDB -import Models - -extension SingleChatInfo: Requestable { - public static func query(_ request: Request) -> QueryInterfaceRequest<SingleChatInfo> { - let lastMessageRequest = Message - .annotated(with: max(Message.Column.timestamp)) - .group(Message.Column.sender || Message.Column.receiver) - - let lastMessageCTE = CommonTableExpression<Message>( - named: "lastMessage", - request: lastMessageRequest - ) - - let lastMessage = Contact.association(to: lastMessageCTE) { contact, lastMessage in - lastMessage[Message.Column.sender] == contact[Contact.Column.userId] || - lastMessage[Message.Column.receiver] == contact[Contact.Column.userId] - }.order(Message.Column.timestamp.desc) - - switch request { - case .all: - return Contact.with(lastMessageCTE) - .including(required: lastMessage) - .asRequest(of: Self.self) - } - } -} diff --git a/Sources/Database/DatabaseManager.swift b/Sources/Database/DatabaseManager.swift deleted file mode 100644 index 86271c2ebfcc146182d867aedf7dd622168807df..0000000000000000000000000000000000000000 --- a/Sources/Database/DatabaseManager.swift +++ /dev/null @@ -1,261 +0,0 @@ -import GRDB -import Models -import Combine -import Foundation - -public protocol Requestable: FetchableRecord { - associatedtype Request - static func query(_ request: Request) -> QueryInterfaceRequest<Self> -} - -public protocol Persistable: Requestable & MutablePersistableRecord & Identifiable { - var id: Int64? { get } -} - -public protocol DatabaseManager { - func drop() - func setup() throws - - func updateAll<T>(_ type: T.Type, - _ request: T.Request, - with: [ColumnAssignment]) throws where T: Persistable - - @discardableResult func save<T>(_ model: T) throws -> T where T: Persistable - func update<T>(_ model: T) throws where T: Persistable - func delete<T>(_ model: T) throws where T: Persistable - func fetch<T>(_ request: T.Request) throws -> [T] where T: Requestable - func fetch<T>(withId id: Int64) throws -> T? where T: Persistable - func publisher<T>(fetch request: T.Request) -> AnyPublisher<[T], Error> where T: Requestable - func delete<T>(_ type: T.Type, _ request: T.Request) throws where T: Persistable -} - -public extension DatabaseManager { - func publisher<T: Requestable>( - fetch type: T.Type, - _ request: T.Request - ) -> AnyPublisher<[T], Error> { - publisher(fetch: request) - } -} - -public final class GRDBDatabaseManager { - var databaseQueue: DatabaseQueue! - - public init() {} -} - -extension GRDBDatabaseManager: DatabaseManager { - public func drop() { - try? databaseQueue.write { db in - try db.drop(table: Contact.databaseTableName) - try db.drop(table: Message.databaseTableName) - try db.drop(table: Group.databaseTableName) - try db.drop(table: GroupMember.databaseTableName) - try db.drop(table: GroupMessage.databaseTableName) - } - } - - public func updateAll<T>(_ type: T.Type, - _ request: T.Request, - with assignments: [ColumnAssignment]) throws where T : Persistable { - _ = try databaseQueue.write { - try T.query(request).updateAll($0, assignments) - } - } - - public func save<T: Persistable>(_ model: T) throws -> T { - try databaseQueue.write { db in - var model = model - - if model.id == nil { - try model.insert(db) - } else { - try model.update(db) - } - - return model - } - } - - public func update<T>(_ model: T) throws where T: Persistable { - try databaseQueue.write { try model.update($0) } - } - - public func fetch<T>(withId id: Int64) throws -> T? where T: Persistable { - try databaseQueue.read { db in - try T.fetchOne(db, key: id) - } - } - - public func fetch<T>(_ request: T.Request) throws -> [T] where T: Requestable { - try databaseQueue.read { db in - try T.query(request).fetchAll(db) - } - } - - public func publisher<T>(fetch request: T.Request) -> AnyPublisher<[T], Error> where T: Requestable { - ValueObservation.tracking { - try T.query(request).fetchAll($0) - }.publisher(in: databaseQueue, scheduling: .immediate) - .eraseToAnyPublisher() - } - - public func delete<T>(_ model: T) throws where T: Persistable { - _ = try databaseQueue.write { - try model.delete($0) - } - } - - public func delete<T>(_ type: T.Type, _ request: T.Request) throws where T: Persistable { - _ = try databaseQueue.write { - try T.query(request).deleteAll($0) - } - } - - public func setup() throws { - var migrator = DatabaseMigrator() - - let oldPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] - .appending("/xxmessenger.sqlite") - - let url = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! - .appendingPathComponent("database") - .appendingPathExtension("sqlite") - - if FileManager.default.fileExists(atPath: oldPath) && !FileManager.default.fileExists(atPath: url.path) { - do { - try FileManager.default.moveItem(atPath: oldPath, toPath: url.path) - } catch { - fatalError("Couldn't migrate database from old path to new one: \(error.localizedDescription)") - } - } - - databaseQueue = try DatabaseQueue(path: url.path) - try FileManager.default.setAttributes([ - .protectionKey : FileProtectionType.completeUntilFirstUserAuthentication - ], ofItemAtPath: url.path) - - migrator.registerMigration("v1") { db in - try db.create(table: Contact.databaseTableName, ifNotExists: true) { table in - table.autoIncrementedPrimaryKey(Contact.Column.id.rawValue, onConflict: .replace) - table.column(Contact.Column.photo.rawValue, .blob) - table.column(Contact.Column.email.rawValue, .text) - table.column(Contact.Column.phone.rawValue, .text) - table.column(Contact.Column.nickname.rawValue, .text) - table.column(Contact.Column.createdAt.rawValue, .datetime) - table.column(Contact.Column.userId.rawValue, .blob).unique() - table.column(Contact.Column.username.rawValue, .text).notNull() - table.column(Contact.Column.status.rawValue, .integer).notNull() - table.column(Contact.Column.marshaled.rawValue, .blob).notNull() - } - - try db.create(table: Message.databaseTableName, ifNotExists: true) { table in - table.autoIncrementedPrimaryKey(Message.Column.id.rawValue, onConflict: .replace) - table.column(Message.Column.report.rawValue, .blob) - table.column(Message.Column.uniqueId.rawValue, .blob) - table.column(Message.Column.sender.rawValue, .blob).notNull() - table.column(Message.Column.payload.rawValue, .text).notNull() - table.column(Message.Column.receiver.rawValue, .blob).notNull() - table.column(Message.Column.roundURL.rawValue, .text) - table.column(Message.Column.status.rawValue, .integer).notNull() - table.column(Message.Column.unread.rawValue, .boolean).notNull() - table.column(Message.Column.timestamp.rawValue, .integer).notNull() - } - - try db.create(table: Group.databaseTableName, ifNotExists: true) { table in - table.autoIncrementedPrimaryKey(Group.Column.id.rawValue, onConflict: .replace) - table.column(Group.Column.groupId.rawValue, .blob).unique() - table.column(Group.Column.name.rawValue, .text).notNull() - table.column(Group.Column.leader.rawValue, .blob).notNull() - table.column(Group.Column.serialize.rawValue, .blob).notNull() - table.column(Group.Column.accepted.rawValue, .boolean).notNull() - } - - try db.create(table: GroupMember.databaseTableName, ifNotExists: true) { table in - table.autoIncrementedPrimaryKey(GroupMember.Column.id.rawValue, onConflict: .replace) - table.column(GroupMember.Column.userId.rawValue, .blob).notNull() - table.column(GroupMember.Column.username.rawValue, .text).notNull() - table.column(GroupMember.Column.photo.rawValue, .blob) - table.column(GroupMember.Column.status.rawValue, .integer).notNull() - table.column(GroupMember.Column.groupId.rawValue, .blob).notNull() - .references( - Group.databaseTableName, - column: Group.Column.groupId.rawValue, - onDelete: .cascade, - deferred: true - ) - } - - try db.create(table: GroupMessage.databaseTableName, ifNotExists: true) { table in - table.autoIncrementedPrimaryKey(GroupMessage.Column.id.rawValue, onConflict: .replace) - table.column(GroupMessage.Column.uniqueId.rawValue, .blob) - table.column(GroupMessage.Column.roundId.rawValue, .integer) - table.column(GroupMessage.Column.groupId.rawValue, .blob).notNull() - table.column(GroupMessage.Column.sender.rawValue, .blob).notNull() - table.column(GroupMessage.Column.roundURL.rawValue, .text) - table.column(GroupMessage.Column.payload.rawValue, .text).notNull() - table.column(GroupMessage.Column.status.rawValue, .integer).notNull() - table.column(GroupMessage.Column.unread.rawValue, .boolean).notNull() - table.column(GroupMessage.Column.timestamp.rawValue, .integer).notNull() - } - - try db.create(table: FileTransfer.databaseTableName, ifNotExists: true) { table in - table.autoIncrementedPrimaryKey(FileTransfer.Column.id.rawValue, onConflict: .replace) - table.column(FileTransfer.Column.tid.rawValue, .blob).notNull() - table.column(FileTransfer.Column.contact.rawValue, .blob).notNull() - table.column(FileTransfer.Column.fileName.rawValue, .text).notNull() - table.column(FileTransfer.Column.fileType.rawValue, .text).notNull() - table.column(FileTransfer.Column.isIncoming.rawValue, .boolean).notNull() - } - } - - migrator.registerMigration("v1: Updating contact/group requests UI") { db in - try db.create(table: "temp_\(Group.databaseTableName)") { table in - table.autoIncrementedPrimaryKey(Group.Column.id.rawValue, onConflict: .replace) - table.column(Group.Column.groupId.rawValue, .blob).unique() - table.column(Group.Column.name.rawValue, .text).notNull() - table.column(Group.Column.leader.rawValue, .blob).notNull() - table.column(Group.Column.serialize.rawValue, .blob).notNull() - table.column(Group.Column.status.rawValue, .integer).notNull() - table.column(Group.Column.createdAt.rawValue, .datetime).notNull() - } - - let oldRows = try Row.fetchCursor(db, sql: "SELECT * FROM \(Group.databaseTableName)") - while let row = try oldRows.next() { - let status: Group.Status - - if row["accepted"] == true { - status = .participating - } else { - status = .pending - } - - try db.execute( - sql: "INSERT INTO temp_\(Group.databaseTableName) (id, groupId, name, leader, serialize, status, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)", - arguments: - [row["id"], - row["groupId"], - row["name"], - row["leader"], - row["serialize"], - status.rawValue, - Date() - ]) - } - - try db.drop(table: Group.databaseTableName) - try db.rename(table: "temp_\(Group.databaseTableName)", to: Group.databaseTableName) - } - - migrator.registerMigration("v2") { db in - try db.alter(table: Contact.databaseTableName) { table in - table.add(column: Contact.Column.isRecent.rawValue, .boolean) - } - - try Contact.updateAll(db, Contact.Column.isRecent.set(to: false)) - } - - try migrator.migrate(databaseQueue) - } -} diff --git a/Sources/DrawerFeature/Items/DrawerTable.swift b/Sources/DrawerFeature/Items/DrawerTable.swift index bf1b8d4432e2220bcdac8372558bfbdccaf91f66..726dae9fe28ccd5720167a7b4ed1944eebf419e5 100644 --- a/Sources/DrawerFeature/Items/DrawerTable.swift +++ b/Sources/DrawerFeature/Items/DrawerTable.swift @@ -74,17 +74,20 @@ public final class DrawerTable: DrawerItem { } public struct DrawerTableCellModel: Hashable { + let id: Data let title: String let image: Data? let isCreator: Bool let isConnection: Bool public init( + id: Data, title: String, image: Data? = nil, isCreator: Bool = false, isConnection: Bool = true ) { + self.id = id self.title = title self.image = image self.isCreator = isCreator diff --git a/Sources/Integration/Client.swift b/Sources/Integration/Client.swift index bdd6784d144195da495cfa24db7784fcdd08b999..7d53fb7efaf8ebe3b4f6485bc38dd101ce8960e5 100644 --- a/Sources/Integration/Client.swift +++ b/Sources/Integration/Client.swift @@ -2,8 +2,9 @@ import Retry import Models import Combine import Defaults -import Foundation import Bindings +import XXModels +import Foundation public class Client { @KeyObject(.inappnotifications, defaultValue: true) var inappnotifications: Bool @@ -21,10 +22,9 @@ public class Client { var messages: AnyPublisher<Message, Never> { messagesSubject.eraseToAnyPublisher() } var requests: AnyPublisher<Contact, Never> { requestsSubject.eraseToAnyPublisher() } var events: AnyPublisher<BackendEvent, Never> { eventsSubject.eraseToAnyPublisher() } + var transfers: AnyPublisher<FileTransfer, Never> { transfersSubject.eraseToAnyPublisher() } var requestsSent: AnyPublisher<Contact, Never> { requestsSentSubject.eraseToAnyPublisher() } var confirmations: AnyPublisher<Contact, Never> { confirmationsSubject.eraseToAnyPublisher() } - var groupMessages: AnyPublisher<GroupMessage, Never> { groupMessagesSubject.eraseToAnyPublisher() } - var incomingTransfers: AnyPublisher<FileTransfer, Never> { transfersSubject.eraseToAnyPublisher() } var groupRequests: AnyPublisher<(Group, [Data], String?), Never> { groupRequestsSubject.eraseToAnyPublisher() } private let backupSubject = PassthroughSubject<Data, Never>() @@ -36,7 +36,6 @@ public class Client { private let requestsSentSubject = PassthroughSubject<Contact, Never>() private let confirmationsSubject = PassthroughSubject<Contact, Never>() private let transfersSubject = PassthroughSubject<FileTransfer, Never>() - private let groupMessagesSubject = PassthroughSubject<GroupMessage, Never>() private let groupRequestsSubject = PassthroughSubject<(Group, [Data], String?), Never>() private var isBackupInitialization = false @@ -110,7 +109,7 @@ public class Client { switch $0 { case .success(var contact): - contact.status = .requested + contact.authStatus = .requested self.requestsSentSubject.send(contact) print(">>> Restored \(contact.username). Setting status as requested") case .failure(let error): @@ -165,8 +164,8 @@ public class Client { groupManager = try bindings.listenGroupRequests { [weak groupRequestsSubject] request, members, welcome in groupRequestsSubject?.send((request, members, welcome)) - } groupMessages: { [weak groupMessagesSubject] in - groupMessagesSubject?.send($0) + } groupMessages: { [weak messagesSubject] in + messagesSubject?.send($0) } bindings.listenPreImageUpdates() @@ -188,19 +187,21 @@ public class Client { /// guard let name = name, let type = type, - let contact = sender, - let _extension = Attachment.Extension.from(type) else { + let contactId = sender else { log(string: "Transfer of \(name ?? "nil").\(type ?? "nil") is being dismissed", type: .error) return } transfersSubject?.send( FileTransfer( - tid: tid, - contact: contact, - fileName: name, - fileType: _extension.written, - isIncoming: true + id: tid, + contactId: contactId, + name: name, + type: type, + data: nil, + progress: 0.0, + isIncoming: true, + createdAt: Date() ) ) } diff --git a/Sources/Integration/Extensions.swift b/Sources/Integration/Extensions.swift index 93cf186a5df2963c605b2dc6a93c7ede36425468..f2afdcb99e46f87141081f94e657b7ef5e14721e 100644 --- a/Sources/Integration/Extensions.swift +++ b/Sources/Integration/Extensions.swift @@ -1,53 +1,58 @@ import Models +import XXModels import Bindings extension Contact { - init(with contact: BindingsContact, status: Contact.Status) { + init(with contact: BindingsContact, status: Contact.AuthStatus) { self.init( - photo: nil, - userId: contact.getID()!, - email: contact.retrieve(fact: .email), - phone: contact.retrieve(fact: .phone), - status: status, + id: contact.getID()!, marshaled: try! contact.marshal(), username: contact.retrieve(fact: .username) ?? "", + email: contact.retrieve(fact: .email), + phone: contact.retrieve(fact: .phone), nickname: nil, - createdAt: Date(), - isRecent: false + photo: nil, + authStatus: status, + isRecent: false, + createdAt: Date() ) } } extension Message { - init(with message: BindingsMessage, meMarshalled: Data) { + init(with message: BindingsMessage, myId: Data) { guard let payload = try? Payload(with: message.getPayload()!) else { fatalError() } self.init( - sender: message.getSender()!, - receiver: meMarshalled, - payload: payload, - unread: true, - timestamp: Int(message.getTimestampNano()), - uniqueId: message.getID()!, + networkId: message.getID()!, + senderId: message.getSender()!, + recipientId: myId, + groupId: nil, + date: Date.fromTimestamp(Int(message.getTimestampNano())), status: .received, - roundURL: message.getRoundURL() + isUnread: true, + text: payload.text, + replyMessageId: payload.reply?.messageId, + roundURL: message.getRoundURL(), + fileTransferId: nil ) } -} -extension GroupMessage { init(with message: BindingsGroupMessageReceive) { guard let payload = try? Payload(with: message.getPayload()!) else { fatalError() } self.init( - sender: message.getSenderID()!, + networkId: message.getMessageID()!, + senderId: message.getSenderID()!, + recipientId: nil, groupId: message.getGroupID()!, - payload: payload, - unread: true, - timestamp: Int(message.getTimestampNano()), - uniqueId: message.getMessageID()!, + date: Date.fromTimestamp(Int(message.getTimestampNano())), status: .received, - roundURL: message.getRoundURL() + isUnread: true, + text: payload.text, + replyMessageId: payload.reply?.messageId, + roundURL: message.getRoundURL(), + fileTransferId: nil ) } } diff --git a/Sources/Integration/Implementations/Bindings.swift b/Sources/Integration/Implementations/Bindings.swift index ce843f4a7f44392a3705f8c738fd3b2cf99a09a4..6aac71d4e5c02c661c719db62816662286f9c308 100644 --- a/Sources/Integration/Implementations/Bindings.swift +++ b/Sources/Integration/Implementations/Bindings.swift @@ -1,8 +1,9 @@ import Shared import Models import Bindings -import DependencyInjection +import XXModels import Foundation +import DependencyInjection public let evaluateNotification: NotificationEvaluation = BindingsNotificationsForMe diff --git a/Sources/Integration/Implementations/GroupManager.swift b/Sources/Integration/Implementations/GroupManager.swift index c51522bea25f9065f485b37ce4a87effc6fcd33d..4b3dc4ef2e03abff18cea433fc30a1f2ed424b33 100644 --- a/Sources/Integration/Implementations/GroupManager.swift +++ b/Sources/Integration/Implementations/GroupManager.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Bindings extension BindingsGroupChat: GroupManagerInterface { @@ -67,15 +68,14 @@ extension BindingsGroupChat: GroupManagerInterface { return } - completion(.success( - .init( - leader: me, - name: name, - groupId: group.getID()!, - status: .participating, - createdAt: Date(), - serialize: group.serialize()! - ))) + completion(.success(.init( + id: group.getID()!, + name: name, + leaderId: me, + createdAt: Date(), + authStatus: .participating, + serialized: group.serialize()! + ))) return default: break diff --git a/Sources/Integration/Implementations/TransferManager.swift b/Sources/Integration/Implementations/TransferManager.swift index 30fe013e648ee1b51d9da740b716553d1bb3972d..8c38a91fcc20eb289d83d0a62d9efb71592f2554 100644 --- a/Sources/Integration/Implementations/TransferManager.swift +++ b/Sources/Integration/Implementations/TransferManager.swift @@ -39,7 +39,7 @@ extension BindingsFileTransfer: TransferManagerInterface { } public func uploadFile( - _ file: Attachment, + url: URL, to recipient: Data, _ callback: @escaping (Bool, Int, Int, Int, Error?) -> Void ) throws -> Data { @@ -47,10 +47,12 @@ extension BindingsFileTransfer: TransferManagerInterface { callback(completed, sent, arrived, total, error) } + guard let file = try? Data(contentsOf: url) else { fatalError() } + return try send( - file.name, - fileType: file._extension.written, - fileData: file.data!, + url.lastPathComponent, + fileType: url.pathExtension, + fileData: file, recipientID: recipient, retry: 1, preview: nil, diff --git a/Sources/Integration/Implementations/UserDiscovery.swift b/Sources/Integration/Implementations/UserDiscovery.swift index b88afa0d8585ef62648eb0af7fe86160fff18b8c..56c9b4de349c6cc807e5a1e7e78a2755ffe7715e 100644 --- a/Sources/Integration/Implementations/UserDiscovery.swift +++ b/Sources/Integration/Implementations/UserDiscovery.swift @@ -1,5 +1,6 @@ import Retry import Models +import XXModels import Bindings import Foundation diff --git a/Sources/Integration/Interfaces/BindingsInterface.swift b/Sources/Integration/Interfaces/BindingsInterface.swift index 34e0e4207c6e1f7625cec55ddf44d7946fa18e7a..b2eef8f94ba3283557530f35ca68e83a2b8dba62 100644 --- a/Sources/Integration/Interfaces/BindingsInterface.swift +++ b/Sources/Integration/Interfaces/BindingsInterface.swift @@ -1,6 +1,7 @@ import Models -import Foundation import Combine +import XXModels +import Foundation public enum MessageDeliveryStatus { case sent @@ -157,7 +158,7 @@ public protocol BindingsInterface { func listenGroupRequests( _: @escaping (Group, [Data], String?) -> Void, - groupMessages: @escaping (GroupMessage) -> Void + groupMessages: @escaping (Message) -> Void ) throws -> GroupManagerInterface? func listenNetworkUpdates(_: @escaping (Bool) -> Void) diff --git a/Sources/Integration/Interfaces/GroupManagerInterface.swift b/Sources/Integration/Interfaces/GroupManagerInterface.swift index c1c5523c6fd3f97f2b343d566469d3996b6dc5d6..dcddfa9e39efe1f67d33a172e28c5cf84b595267 100644 --- a/Sources/Integration/Interfaces/GroupManagerInterface.swift +++ b/Sources/Integration/Interfaces/GroupManagerInterface.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Foundation public protocol GroupManagerInterface { diff --git a/Sources/Integration/Interfaces/TransferManagerInterface.swift b/Sources/Integration/Interfaces/TransferManagerInterface.swift index 294dd0ab32ce27d4fed3367002fdce45dadbedd9..b95d1ee34d2cd154192f74b336a44d6deed9dc69 100644 --- a/Sources/Integration/Interfaces/TransferManagerInterface.swift +++ b/Sources/Integration/Interfaces/TransferManagerInterface.swift @@ -1,4 +1,3 @@ -import Models import Foundation public protocol TransferManagerInterface { @@ -20,9 +19,8 @@ public protocol TransferManagerInterface { with: Data ) throws -> Data - func uploadFile( - _: Attachment, + url: URL, to: Data, _: @escaping (Bool, Int, Int, Int, Error?) -> Void ) throws -> Data diff --git a/Sources/Integration/Interfaces/UserDiscoveryInterface.swift b/Sources/Integration/Interfaces/UserDiscoveryInterface.swift index 07398985fd1a25e500bb9015832d18e07226b57b..ded311ecdf19e7b179b2f91d325588f57a33ff2d 100644 --- a/Sources/Integration/Interfaces/UserDiscoveryInterface.swift +++ b/Sources/Integration/Interfaces/UserDiscoveryInterface.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Foundation public struct LookupResult { diff --git a/Sources/Integration/Listeners.swift b/Sources/Integration/Listeners.swift index 23f9f2234250a113862d693254238c8bc8664b74..7ab877603cb879c6d660384a0a596e259a8856e3 100644 --- a/Sources/Integration/Listeners.swift +++ b/Sources/Integration/Listeners.swift @@ -1,13 +1,10 @@ import Models import Shared -import Bindings -import Foundation import os.log import Combine - -import Combine - -import Combine +import XXModels +import Bindings +import Foundation public extension BindingsClient { static func listenLogs() { @@ -45,7 +42,7 @@ public extension BindingsClient { let listener = TextListener { bindingsMessage in guard let message = bindingsMessage else { return } - let domainModel = Message(with: message, meMarshalled: self.meMarshalled) + let domainModel = Message(with: message, myId: self.myId) callback(domainModel) } @@ -111,7 +108,7 @@ public extension BindingsClient { func listenGroupRequests( _ groupRequests: @escaping (Group, [Data], String?) -> Void, - groupMessages: @escaping (GroupMessage) -> Void + groupMessages: @escaping (Message) -> Void ) throws -> GroupManagerInterface? { var error: NSError? @@ -136,16 +133,16 @@ public extension BindingsClient { } groupRequests(.init( - leader: members.first!, + id: id, name: String(data: name, encoding: .utf8)!, - groupId: id, - status: .pending, + leaderId: members.first!, createdAt: Date(), - serialize: serialize + authStatus: .pending, + serialized: serialize ), members, welcomeMessage) } - let messageCallback = GroupMessageCallback { groupMessages(GroupMessage(with: $0)) } + let messageCallback = GroupMessageCallback { groupMessages(Message(with: $0)) } let groupManager = BindingsNewGroupManager(self, requestCallback, messageCallback, &error) guard let error = error else { return groupManager } diff --git a/Sources/Integration/Mocks/BindingsMock.swift b/Sources/Integration/Mocks/BindingsMock.swift index 8eaf8a0688d3efdaab0ba1ccedad20bd4b6c7577..61ccceb42f58ccae7adc40a76c4e13d230f53a47 100644 --- a/Sources/Integration/Mocks/BindingsMock.swift +++ b/Sources/Integration/Mocks/BindingsMock.swift @@ -1,5 +1,6 @@ import Models import Combine +import XXModels import Foundation public final class BindingsMock: BindingsInterface { @@ -162,7 +163,7 @@ public final class BindingsMock: BindingsInterface { public func listenGroupRequests( _ groupRequests: @escaping (Group, [Data], String?) -> Void, - groupMessages: @escaping (GroupMessage) -> Void + groupMessages: @escaping (Message) -> Void ) throws -> GroupManagerInterface? { groupRequestsSubject .sink { groupRequests($0, [], nil) } @@ -187,12 +188,12 @@ public final class BindingsMock: BindingsInterface { extension Group { static let mockGroup = Group( - leader: "mockGroupLeader".data(using: .utf8)!, + id: "mockGroup".data(using: .utf8)!, name: "Bruno's birthday 6/1", - groupId: "mockGroup".data(using: .utf8)!, - status: .pending, + leaderId: "mockGroupLeader".data(using: .utf8)!, createdAt: Date.distantPast, - serialize: "mockGroup".data(using: .utf8)! + authStatus: .pending, + serialized: "mockGroup".data(using: .utf8)! ) } @@ -201,17 +202,18 @@ extension Contact { var mocks = [Contact]() for n in 0..<count { - mocks.append(.init( - photo: nil, - userId: "brad\(n)".data(using: .utf8)!, - email: "brad\(n)@xx.io", - phone: "819820212\(n)5BR", - status: .verified, - marshaled: "brad\(n)".data(using: .utf8)!, - username: "brad\(n)", - nickname: nil, - createdAt: Date(), - isRecent: false + mocks.append( + .init( + id: "brad\(n)".data(using: .utf8)!, + marshaled: "brad\(n)".data(using: .utf8)!, + username: "brad\(n)", + email: "brad\(n)@xx.io", + phone: "819820212\(n)5BR", + nickname: nil, + photo: nil, + authStatus: .verified, + isRecent: false, + createdAt: Date() )) } @@ -219,55 +221,55 @@ extension Contact { } static let angelinaRequested = Contact( - photo: nil, - userId: "angelinajolie".data(using: .utf8)!, - email: nil, - phone: nil, - status: .verificationInProgress, + id: "angelinajolie".data(using: .utf8)!, marshaled: "angelinajolie".data(using: .utf8)!, username: "angelinajolie", + email: nil, + phone: nil, nickname: "Angelica Jolie", - createdAt: Date(), - isRecent: false + photo: nil, + authStatus: .verificationInProgress, + isRecent: false, + createdAt: Date() ) static let carlRequested = Contact( - photo: nil, - userId: "carlsagan".data(using: .utf8)!, - email: "carl@jpl.nasa", - phone: "81982022244BR", - status: .verified, + id: "carlsagan".data(using: .utf8)!, marshaled: "carlsagan".data(using: .utf8)!, username: "carlsagan", + email: "carl@jpl.nasa", + phone: "81982022244BR", nickname: "Carl Sagan", - createdAt: Date.distantPast, - isRecent: false + photo: nil, + authStatus: .verified, + isRecent: false, + createdAt: Date.distantPast ) static let elonRequested = Contact( - photo: nil, - userId: "elonmusk".data(using: .utf8)!, - email: "elon@tesla.com", - phone: nil, - status: .verified, + id: "elonmusk".data(using: .utf8)!, marshaled: "elonmusk".data(using: .utf8)!, username: "elonmusk", + email: "elon@tesla.com", + phone: nil, nickname: "Elon Musk", - createdAt: Date.distantPast, - isRecent: false + photo: nil, + authStatus: .verified, + isRecent: false, + createdAt: Date.distantPast ) static let georgeDiscovered = Contact( - photo: nil, - userId: "georgebenson74".data(using: .utf8)!, - email: "george@xx.io", - phone: "81987022255BR", - status: .stranger, + id: "georgebenson74".data(using: .utf8)!, marshaled: "georgebenson74".data(using: .utf8)!, username: "bruno_muniz74", + email: "george@xx.io", + phone: "81987022255BR", nickname: "Bruno Muniz", - createdAt: Date(), - isRecent: false + photo: nil, + authStatus: .stranger, + isRecent: false, + createdAt: Date() ) } diff --git a/Sources/Integration/Mocks/GroupManagerMock.swift b/Sources/Integration/Mocks/GroupManagerMock.swift index ecec61c7483c7431a84d4c4e261e7cf0e70789d5..137cfca69c25a188f20c60af867befc3d79a04cf 100644 --- a/Sources/Integration/Mocks/GroupManagerMock.swift +++ b/Sources/Integration/Mocks/GroupManagerMock.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Foundation final class GroupManagerMock: GroupManagerInterface { diff --git a/Sources/Integration/Mocks/TransferManagerMock.swift b/Sources/Integration/Mocks/TransferManagerMock.swift index cd4b812e4cbfeed4f197585b3921e764f665e7c9..9b87da5d214d5cf0dcc5d39cdd88a979ff1a6e76 100644 --- a/Sources/Integration/Mocks/TransferManagerMock.swift +++ b/Sources/Integration/Mocks/TransferManagerMock.swift @@ -1,4 +1,3 @@ -import Models import Foundation final class TransferManagerMock: TransferManagerInterface { @@ -25,7 +24,7 @@ final class TransferManagerMock: TransferManagerInterface { } func uploadFile( - _: Attachment, + url: URL, to: Data, _: @escaping (Bool, Int, Int, Int, Error?) -> Void ) throws -> Data { diff --git a/Sources/Integration/Mocks/UserDiscoveryMock.swift b/Sources/Integration/Mocks/UserDiscoveryMock.swift index 910faf080c1b476af9c63677a25a6211f8f8a448..bb1a8f2440ce0baf65bd8eb5dff4ef305babb842 100644 --- a/Sources/Integration/Mocks/UserDiscoveryMock.swift +++ b/Sources/Integration/Mocks/UserDiscoveryMock.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Foundation final class UserDiscoveryMock: UserDiscoveryInterface { @@ -26,17 +27,18 @@ final class UserDiscoveryMock: UserDiscoveryInterface { _ completion: @escaping (Result<Contact, Error>) -> Void ) { DispatchQueue.global().asyncAfter(deadline: .now() + 1) { - completion(.success(.init( - photo: nil, - userId: "mock_username".data(using: .utf8)!, - email: nil, - phone: nil, - status: .stranger, - marshaled: "mock_username".data(using: .utf8)!, - username: "mock_username", - nickname: "mock_nickname", - createdAt: Date(), - isRecent: false + completion(.success( + .init( + id: "mock_username".data(using: .utf8)!, + marshaled: "mock_username".data(using: .utf8)!, + username: "mock_username", + email: nil, + phone: nil, + nickname: "mock_nickname", + photo: nil, + authStatus: .stranger, + isRecent: false, + createdAt: Date() ))) } } diff --git a/Sources/Integration/Session/Session+Chat.swift b/Sources/Integration/Session/Session+Chat.swift index 5bdf71c60b733813df42fadfa5290e011b54e8d0..3e3eb63a7df4c8d2dcc1ab315335f8fb046b6d69 100644 --- a/Sources/Integration/Session/Session+Chat.swift +++ b/Sources/Integration/Session/Session+Chat.swift @@ -1,63 +1,32 @@ -import Models -import Foundation import UIKit +import Models import Shared +import XXModels +import Foundation extension Session { - public func readAll(from contact: Contact) { - do { - try dbManager.updateAll( - Message.self, - Message.Request.unreadsFromContactId(contact.userId), - with: [Message.Column.unread.set(to: false)] - ) - } catch { - log(string: error.localizedDescription, type: .error) - } - } - - public func readAll(from group: Group) { - do { - try dbManager.updateAll( - GroupMessage.self, - GroupMessage.Request.unreadsFromGroup(group.groupId), - with: [GroupMessage.Column.unread.set(to: false)] - ) - } catch { - log(string: error.localizedDescription, type: .error) - } - } - - public func deleteAll(from contact: Contact) { - do { - try dbManager.delete(Message.self, .withContact(contact.userId)) - } catch { - log(string: error.localizedDescription, type: .error) - } - } - - public func deleteAll(from group: Group) { - do { - try dbManager.delete(GroupMessage.self, .fromGroup(group.groupId)) - } catch { - log(string: error.localizedDescription, type: .error) - } - } - public func send(imageData: Data, to contact: Contact, completion: @escaping (Result<Void, Error>) -> Void) { - client.bindings.compress(image: imageData) { [weak self] in + client.bindings.compress(image: imageData) { [weak self] result in guard let self = self else { completion(.success(())) return } - switch $0 { - case .success(let compressed): - let name = "image_\(Date.asTimestamp)" - try! FileManager.store(data: compressed, name: name, type: Attachment.Extension.image.written) - let attachment = Attachment(name: name, data: compressed, _extension: .image) - self.send(Payload(text: "You sent an image", reply: nil, attachment: attachment), toContact: contact) - completion(.success(())) + switch result { + case .success(let compressedImage): + do { + let url = try FileManager.store( + data: compressedImage, + name: "image_\(Date.asTimestamp)", + type: "jpeg" + ) + + self.sendFile(url: url, to: contact) + completion(.success(())) + } catch { + completion(.failure(error)) + } + case .failure(let error): completion(.failure(error)) log(string: "Error when compressing image: \(error.localizedDescription)", type: .error) @@ -65,59 +34,119 @@ extension Session { } } - public func send(_ payload: Payload, toContact contact: Contact) { - var message = Message( - sender: client.bindings.meMarshalled, - receiver: contact.userId, - payload: payload, - unread: false, - timestamp: Date.asTimestamp, - uniqueId: nil, - status: payload.attachment == nil ? .sending : .sendingAttachment - ) + public func sendFile(url: URL, to contact: Contact) { + guard let manager = client.transferManager else { fatalError("A transfer manager was not created") } - do { - message = try dbManager.save(message) - send(message: message) - } catch { - log(string: error.localizedDescription, type: .error) - } - } + DispatchQueue.global().async { [weak self] in + guard let self = self else { return } + + var tid: Data? - public func delete(messages: [Int64]) { - messages.forEach { do { - try dbManager.delete(Message.self, .withId($0)) + tid = try manager.uploadFile(url: url, to: contact.id) { completed, send, arrived, total, error in + guard let tid = tid else { return } + + if completed { + self.endTransferWith(tid: tid) + } else { + if error != nil { + self.failTransferWith(tid: tid) + } else { + self.progressTransferWith(tid: tid, arrived: Float(arrived), total: Float(total)) + } + } + } + + guard let tid = tid else { return } + + let content = url.pathExtension == "m4a" ? "a voice message" : "an image" + + let transfer = FileTransfer( + id: tid, + contactId: contact.id, + name: url.deletingPathExtension().lastPathComponent, + type: url.pathExtension, + data: try? Data(contentsOf: url), + progress: 0.0, + isIncoming: false, + createdAt: Date() + ) + + _ = try? self.dbManager.saveFileTransfer(transfer) + + let message = Message( + networkId: nil, + senderId: self.client.bindings.myId, + recipientId: contact.id, + groupId: nil, + date: Date(), + status: .sending, + isUnread: false, + text: "You sent \(content)", + replyMessageId: nil, + roundURL: nil, + fileTransferId: tid + ) + + _ = try? self.dbManager.saveMessage(message) } catch { - log(string: error.localizedDescription, type: .error) + print(error.localizedDescription) } } } - public func retryMessage(_ id: Int64) { - guard var message: Message = try? dbManager.fetch(withId: id) else { return } - message.timestamp = Date.asTimestamp - message.status = message.payload.attachment == nil ? .sending : .sendingAttachment + public func send(_ payload: Payload, toContact contact: Contact) { + var message = Message( + networkId: nil, + senderId: client.bindings.myId, + recipientId: contact.id, + groupId: nil, + date: Date(), + status: .sending, + isUnread: false, + text: payload.text, + replyMessageId: payload.reply?.messageId, + roundURL: nil, + fileTransferId: nil + ) do { - message = try dbManager.save(message) + message = try dbManager.saveMessage(message) send(message: message) } catch { log(string: error.localizedDescription, type: .error) } } - private func send(message: Message) { + public func retryMessage(_ id: Int64) { + if var message = try? dbManager.fetchMessages(.init(id: [id])).first { + message.status = .sending + message.date = Date() + + if let message = try? dbManager.saveMessage(message) { + if let recipientId = message.recipientId { + send(message: message) + } else { + send(groupMessage: message) + } + } + } + } + + func send(message: Message) { var message = message - if let _ = message.payload.attachment { - sendAttachment(message: message) - return + var reply: Reply? + if let replyId = message.replyMessageId, + let replyMessage = try? dbManager.fetchMessages(Message.Query(networkId: replyId)).first { + reply = Reply(messageId: replyId, senderId: replyMessage.senderId) } + let payloadData = Payload(text: message.text, reply: reply).asData() + DispatchQueue.global().async { [weak self] in guard let self = self else { return } - switch self.client.bindings.send(message.payload.asData(), to: message.receiver) { + switch self.client.bindings.send(payloadData, to: message.recipientId!) { case .success(let report): message.roundURL = report.roundURL @@ -126,34 +155,34 @@ extension Session { case .success(let status): switch status { case .failed: - message.status = .failedToSend + message.status = .sendingFailed case .sent: message.status = .sent case .timedout: - message.status = .timedOut + message.status = .sendingTimedOut } case .failure: - message.status = .failedToSend + message.status = .sendingFailed } - message.uniqueId = report.uniqueId - message.timestamp = Int(report.timestamp) + message.networkId = report.uniqueId + message.date = Date.fromTimestamp(Int(report.timestamp)) DispatchQueue.main.async { do { - _ = try self.dbManager.save(message) + _ = try self.dbManager.saveMessage(message) } catch { log(string: error.localizedDescription, type: .error) } } } case .failure(let error): - message.status = .failedToSend + message.status = .sendingFailed log(string: error.localizedDescription, type: .error) } DispatchQueue.main.async { do { - _ = try self.dbManager.save(message) + _ = try self.dbManager.saveMessage(message) } catch { log(string: error.localizedDescription, type: .error) } @@ -161,157 +190,77 @@ extension Session { } } - private func sendAttachment(message: Message) { - guard let manager = client.transferManager else { fatalError("A transfer manager was not created") } - - var message = message - let attachment = message.payload.attachment! - - DispatchQueue.global().async { [weak self] in - guard let self = self else { return } - - do { - let tid = try manager.uploadFile(attachment, to: message.receiver) { completed, send, arrived, total, error in - if completed { - self.endTransferFrom(message: message) - message.status = .sent - message.payload.attachment?.progress = 1.0 - log(string: "FT Up finished", type: .info) - } else { - if let error = error { - log(string: error.localizedDescription, type: .error) - message.status = .failedToSend - } else { - let progress = Float(arrived)/Float(total) - message.payload.attachment?.progress = progress - log(string: "FT Up: \(progress)", type: .crumbs) - } - } - - do { - _ = try self.dbManager.save(message) // If it fails here, means the chat was cleared. - } catch { - log(string: error.localizedDescription, type: .error) - } - } - - let transfer = FileTransfer( - tid: tid, - contact: message.receiver, - fileName: attachment.name, - fileType: attachment._extension.written, - isIncoming: false - ) + private func endTransferWith(tid: Data) { + guard let manager = client.transferManager else { + fatalError("A transfer manager was not created") + } - message.payload.attachment?.transferId = tid - message.status = .sendingAttachment + try? manager.endTransferUpload(with: tid) - do { - _ = try self.dbManager.save(message) - _ = try self.dbManager.save(transfer) - } catch { - log(string: error.localizedDescription, type: .error) - } - } catch { - message.status = .failedToSend - log(string: error.localizedDescription, type: .error) + if var message = try? dbManager.fetchMessages(.init(fileTransferId: tid)).first { + message.status = .sent + _ = try? dbManager.saveMessage(message) + } - do { - _ = try self.dbManager.save(message) - } catch let otherError { - log(string: otherError.localizedDescription, type: .error) - } - } + if var transfer = try? dbManager.fetchFileTransfers(.init(id: [tid])).first { + transfer.progress = 1.0 + _ = try? dbManager.saveFileTransfer(transfer) } } - private func endTransferFrom(message: Message) { - guard let manager = client.transferManager else { fatalError("A transfer manager was not created") } - guard let tid = message.payload.attachment?.transferId else { fatalError("Tried to finish a transfer that had no TID") } - - do { - try manager.endTransferUpload(with: tid) + private func failTransferWith(tid: Data) { + if var message = try? dbManager.fetchMessages(.init(fileTransferId: tid)).first { + message.status = .sendingFailed + _ = try? dbManager.saveMessage(message) + } + } - if let transfer: FileTransfer = try? dbManager.fetch(.withTID(tid)).first { - try dbManager.delete(transfer) - } - } catch { - log(string: error.localizedDescription, type: .error) + private func progressTransferWith(tid: Data, arrived: Float, total: Float) { + if var transfer = try? dbManager.fetchFileTransfers(.init(id: [tid])).first { + transfer.progress = arrived/total + _ = try? dbManager.saveFileTransfer(transfer) } } func handle(incomingTransfer transfer: FileTransfer) { - guard let manager = client.transferManager else { fatalError("A transfer manager was not created") } - - let fileExtension: Attachment.Extension = transfer.fileType == "m4a" ? .audio : .image - let name = "\(Date.asTimestamp)_\(transfer.fileName)" - - var fakeContent: Data - - if fileExtension == .image { - fakeContent = Asset.transferImagePlaceholder.image.jpegData(compressionQuality: 0.1)! - } else { - fakeContent = FileManager.dummyAudio() + guard let manager = client.transferManager else { + fatalError("A transfer manager was not created") } - let attachment = Attachment(name: name, data: fakeContent, transferId: transfer.tid, _extension: fileExtension) + let content = transfer.type == "m4a" ? "a voice message" : "an image" var message = Message( - sender: transfer.contact, - receiver: client.bindings.meMarshalled, - payload: .init(text: "Sent you a \(fileExtension.writtenExtended)", reply: nil, attachment: attachment), - unread: true, - timestamp: Date.asTimestamp, - uniqueId: nil, - status: .receivingAttachment + networkId: nil, + senderId: transfer.contactId, + recipientId: myId, + groupId: nil, + date: transfer.createdAt, + status: .receiving, + isUnread: true, + text: "Sent you \(content)", + replyMessageId: nil, + roundURL: nil, + fileTransferId: transfer.id ) - do { - message = try self.dbManager.save(message) - try self.dbManager.save(transfer) - } catch { - log(string: "Failed to save message/transfer to the database. Will not start listening to transfer... \(error.localizedDescription)", type: .info) - return - } - - log(string: "FT Down starting", type: .info) + message = try! self.dbManager.saveMessage(message) - try! manager.listenDownloadFromTransfer(with: transfer.tid) { completed, arrived, total, error in - if let error = error { - fatalError(error.localizedDescription) - } + try! manager.listenDownloadFromTransfer(with: transfer.id) { completed, arrived, total, error in + if let error = error { fatalError(error.localizedDescription) } if completed { - log(string: "FT Down finished", type: .info) + guard let rawFile = try? manager.downloadFileFromTransfer(with: transfer.id) else { return } + _ = try! FileManager.store(data: rawFile, name: transfer.name, type: transfer.type) - guard let rawFile = try? manager.downloadFileFromTransfer(with: transfer.tid) else { - log(string: "Received finalized transfer, file was nil. Ignoring...", type: .error) - return - } + var transfer = transfer + transfer.data = rawFile + transfer.progress = 1.0 + _ = try? self.dbManager.saveFileTransfer(transfer) - try! FileManager.store(data: rawFile, name: name, type: fileExtension.written) - var realAttachment = Attachment(name: name, data: rawFile, transferId: transfer.tid, _extension: fileExtension) - realAttachment.progress = 1.0 - message.payload = .init(text: "Sent you a \(transfer.fileType)", reply: nil, attachment: realAttachment) message.status = .received - - if let toDelete: FileTransfer = try? self.dbManager.fetch(.withTID(transfer.tid)).first { - do { - try self.dbManager.delete(toDelete) - } catch { - log(string: error.localizedDescription, type: .error) - } - } + _ = try? self.dbManager.saveMessage(message) } else { - let progress = Float(arrived)/Float(total) - log(string: "FT Down: \(progress)", type: .crumbs) - message.payload.attachment?.progress = progress - } - - do { - try self.dbManager.save(message) // If it fails here, means the chat was cleared. - } catch { - log(string: "Failed to update message model from an incoming transfer. Probably chat was cleared: \(error.localizedDescription)", type: .error) + self.progressTransferWith(tid: transfer.id, arrived: Float(arrived), total: Float(total)) } } } diff --git a/Sources/Integration/Session/Session+Contacts.swift b/Sources/Integration/Session/Session+Contacts.swift index 53204708f1fdb09e27d1f48b0bbbde0a76c40a90..fa27fe2ece740c14217d809d8bab6fd48fae670a 100644 --- a/Sources/Integration/Session/Session+Contacts.swift +++ b/Sources/Integration/Session/Session+Contacts.swift @@ -1,7 +1,7 @@ import Retry import Models import Shared -import Database +import XXModels import Foundation extension Session { @@ -10,13 +10,11 @@ extension Session { } public func verify(contact: Contact) { - log(string: "Requested verification of \(contact.username)", type: .crumbs) - var contact = contact - contact.status = .verificationInProgress + contact.authStatus = .verificationInProgress do { - contact = try dbManager.save(contact) + contact = try dbManager.saveContact(contact) } catch { log(string: "Failed to store contact request upon verification. Returning, request will be abandoned to not crash", type: .error) } @@ -31,16 +29,12 @@ extension Session { return } - log(string: "Network is available. Verifying \(contact.username)", type: .crumbs) - let resultClosure: (Result<Contact, Error>) -> Void = { result in switch result { case .success(let mightBe): - guard try! self.client.bindings.verify(marshaled: contact.marshaled, verifiedMarshaled: mightBe.marshaled) else { - log(string: "\(contact.username) is fake. Deleted!", type: .info) - + guard try! self.client.bindings.verify(marshaled: contact.marshaled!, verifiedMarshaled: mightBe.marshaled!) else { do { - try self.dbManager.delete(contact) + try self.dbManager.deleteContact(contact) } catch { log(string: error.localizedDescription, type: .error) } @@ -48,21 +42,19 @@ extension Session { return } - contact.status = .verified - log(string: "Verified \(contact.username)", type: .info) + contact.authStatus = .verified do { - try self.dbManager.save(contact) + try self.dbManager.saveContact(contact) } catch { log(string: error.localizedDescription, type: .error) } - case .failure(let error): - log(string: "Verification of \(contact.username) failed: \(error.localizedDescription)", type: .error) - contact.status = .verificationFailed + case .failure: + contact.authStatus = .verificationFailed do { - try self.dbManager.save(contact) + try self.dbManager.saveContact(contact) } catch { log(string: error.localizedDescription, type: .error) } @@ -75,7 +67,7 @@ extension Session { let hasPhone = contact.phone != nil guard hasEmail || hasPhone else { - ud.lookup(forUserId: contact.userId, resultClosure) + ud.lookup(forUserId: contact.id, resultClosure) return } @@ -91,10 +83,10 @@ extension Session { try ud.search(fact: fact, resultClosure) } catch { log(string: error.localizedDescription, type: .error) - contact.status = .verificationFailed + contact.authStatus = .verificationFailed do { - try self.dbManager.save(contact) + try self.dbManager.saveContact(contact) } catch { log(string: error.localizedDescription, type: .error) } @@ -104,7 +96,7 @@ extension Session { public func retryRequest(_ contact: Contact) throws { log(string: "Retrying to request a contact", type: .info) - client.bindings.add(contact.marshaled, from: myQR) { [weak self, contact] in + client.bindings.add(contact.marshaled!, from: myQR) { [weak self, contact] in var contact = contact guard let self = self else { return } @@ -112,13 +104,13 @@ extension Session { switch $0 { case .success: log(string: "Retrying to request a contact -- Success", type: .info) - contact.status = .requested + contact.authStatus = .requested case .failure(let error): log(string: "Retrying to request a contact -- Failed: \(error.localizedDescription)", type: .error) contact.createdAt = Date() } - _ = try self.dbManager.save(contact) + _ = try self.dbManager.saveContact(contact) } catch { log(string: error.localizedDescription, type: .error) } @@ -132,23 +124,23 @@ extension Session { var contactToOperate: Contact! - if contact.status == .requestFailed || contact.status == .confirmationFailed { + if [.requestFailed, .confirmationFailed, .stranger].contains(contact.authStatus) { contactToOperate = contact } else { - guard (try? dbManager.fetch(.withUsername(contact.username)).first as Contact?) == nil else { + if let _ = try? dbManager.fetchContacts(.init(id: [contact.id])).first { throw NSError.create("This user has already been requested") } - contactToOperate = try dbManager.save(contact) + contactToOperate = try dbManager.saveContact(contact) } - guard contactToOperate.status != .confirmationFailed else { + guard contactToOperate.authStatus != .confirmationFailed else { contactToOperate.createdAt = Date() try confirm(contact) return } - contactToOperate.status = .requesting + contactToOperate.authStatus = .requesting let myself = client.bindings.meMarshalled( username!, @@ -156,119 +148,81 @@ extension Session { phone: isSharingPhone ? phone : nil ) - client.bindings.add(contactToOperate.marshaled, from: myself) { [weak self, contactToOperate] in + client.bindings.add(contactToOperate.marshaled!, from: myself) { [weak self, contactToOperate] in guard let self = self, var contactToOperate = contactToOperate else { return } - let safeName = contactToOperate.nickname ?? contactToOperate.username - let title = "\(safeName.prefix(2))...\(safeName.suffix(3))" do { switch $0 { case .success(let success): - contactToOperate.status = success ? .requested : .requestFailed - contactToOperate = try self.dbManager.save(contactToOperate) + contactToOperate.authStatus = success ? .requested : .requestFailed + contactToOperate = try self.dbManager.saveContact(contactToOperate) - log(string: "Successfully added \(title)", type: .info) - case .failure(let error): - contactToOperate.status = .requestFailed + case .failure: + contactToOperate.authStatus = .requestFailed contactToOperate.createdAt = Date() - contactToOperate = try self.dbManager.save(contactToOperate) - - log(string: "Failed when adding \(title):\n\(error.localizedDescription)", type: .error) + contactToOperate = try self.dbManager.saveContact(contactToOperate) self.toastController.enqueueToast(model: .init( - title: Localized.Requests.Failed.toast(contactToOperate.nickname ?? contact.username), + title: Localized.Requests.Failed.toast(contactToOperate.nickname ?? contact.username!), color: Asset.accentDanger.color, leftImage: Asset.requestFailedToaster.image )) } } catch { - log(string: "Error adding \(title):\n\(error.localizedDescription)", type: .error) + print(error.localizedDescription) } } } public func confirm(_ contact: Contact) throws { var contact = contact - contact.status = .confirming - contact = try dbManager.save(contact) - - client.bindings.confirm(contact.marshaled) { [weak self] in - let safeName = contact.nickname ?? contact.username - let title = "\(safeName.prefix(2))...\(safeName.suffix(3))" + contact.authStatus = .confirming + contact = try dbManager.saveContact(contact) + client.bindings.confirm(contact.marshaled!) { [weak self] in switch $0 { case .success(let confirmed): contact.isRecent = true contact.createdAt = Date() - contact.status = confirmed ? .friend : .confirmationFailed - log(string: "Confirming request from \(title) = \(confirmed)", type: confirmed ? .info : .error) - case .failure(let error): - contact.status = .confirmationFailed - log(string: "Error confirming request from \(title):\n\(error.localizedDescription)", type: .error) - } + contact.authStatus = confirmed ? .friend : .confirmationFailed - _ = try? self?.dbManager.save(contact) - } - } - - public func update(_ contact: Contact) { - do { - if var stored = try dbManager.fetch(.withUsername(contact.username)).first as Contact? { - stored.email = contact.email - stored.photo = contact.photo - stored.phone = contact.phone - stored.nickname = contact.nickname - stored.isRecent = contact.isRecent - stored.createdAt = contact.createdAt - try dbManager.save(stored) - - try dbManager.updateAll( - GroupMember.self, - GroupMember.Request.withUserId(stored.userId), - with: [GroupMember.Column.photo.set(to: stored.photo)] - ) + case .failure: + contact.authStatus = .confirmationFailed } - } catch { - log(string: "Error updating a contact: \(error.localizedDescription)", type: .error) - } - } - - public func delete<T: Persistable>(_ model: T, isRequest: Bool = false) { - log(string: "Deleting a model...", type: .info) - do { - try dbManager.delete(model) - } catch { - log(string: "Error deleting a model: \(error.localizedDescription)", type: .error) - } - } - - public func find(by username: String) -> Contact? { - log(string: "Trying to find contact with username: \(username)", type: .info) - - do { - if let contact: Contact = try dbManager.fetch(.withUsername(username)).first { - log(string: "Found \(username)!", type: .info) - return contact - } else { - log(string: "No such contact with username: \(username)", type: .info) - return nil - } - } catch { - log(string: "Error trying to find a contact: \(error.localizedDescription)", type: .error) + _ = try? self?.dbManager.saveContact(contact) } - - return nil } public func deleteContact(_ contact: Contact) throws { - if let _: FileTransfer = try? dbManager.fetch(.withContactId(contact.userId)).first { + if !(try dbManager.fetchFileTransfers(.init(contactId: contact.id))).isEmpty { throw NSError.create("There is an ongoing file transfer with this contact as you are receiving or sending a file, please try again later once it’s done") - } else { - print("No pending transfer with this contact. Free to delete") } - try client.bindings.removeContact(contact.marshaled) - try dbManager.delete(contact) + try client.bindings.removeContact(contact.marshaled!) + + /// Currently this cascades into deleting + /// all messages w/ contact.id == senderId + /// But this shouldn't be the always the case + /// because if we have a group / this contact + /// the messages will be gone as well. + /// + /// Suggestion: If there's a group where this user belongs to + /// we will just cleanup the contact model stored on the db + /// leaving only username and id which are the equivalent to + /// .stranger contacts. + /// + //try dbManager.deleteContact(contact) + + _ = try? dbManager.deleteMessages(Message.Query(chat: .direct(myId, contact.id))) + var contact = contact + contact.email = nil + contact.phone = nil + contact.photo = nil + contact.isRecent = false + contact.marshaled = nil + contact.authStatus = .stranger + contact.nickname = contact.username + _ = try? dbManager.saveContact(contact) } } diff --git a/Sources/Integration/Session/Session+Group.swift b/Sources/Integration/Session/Session+Group.swift index 78670c9b0b9083dfa09ee555101a2479a153809f..a3cf68964a307dceecb77893eb5f67fe15367bab 100644 --- a/Sources/Integration/Session/Session+Group.swift +++ b/Sources/Integration/Session/Session+Group.swift @@ -1,38 +1,73 @@ import Models +import XXModels import Foundation -public typealias GroupCompletion = (Result<(Group, [GroupMember]), Error>) -> Void - extension Session { public func join(group: Group) throws { guard let manager = client.groupManager else { fatalError("A group manager was not created") } - try manager.join(group.serialize) + try manager.join(group.serialized) var group = group - group.status = .participating + group.authStatus = .participating scanStrangers {} - try dbManager.save(group) + try dbManager.saveGroup(group) } public func leave(group: Group) throws { guard let manager = client.groupManager else { fatalError("A group manager was not created") } - try manager.leave(group.groupId) - try dbManager.delete(group) + try manager.leave(group.id) + try dbManager.deleteGroup(group) } - public func createGroup(name: String, welcome: String?, members: [Contact], _ completion: @escaping GroupCompletion) { - guard let manager = client.groupManager else { fatalError("A group manager was not created") } - - let me = client.bindings.meMarshalled - let memberIds = members.map { $0.userId } + public func createGroup( + name: String, + welcome: String?, + members: [Contact], + _ completion: @escaping (Result<GroupInfo, Error>) -> Void + ) { + guard let manager = client.groupManager else { + fatalError("A group manager was not created") + } - manager.create(me: me, name: name, welcome: welcome, with: memberIds) { [weak self] in + manager.create( + me: myId, + name: name, + welcome: welcome, + with: members.map { $0.id }) { [weak self] result in guard let self = self else { return } - switch $0 { + switch result { case .success(let group): - completion(.success((group, self.processGroupCreation(group, memberIds: memberIds, welcome: welcome)))) - break + try! self.dbManager.saveGroup(group) + + members + .map { GroupMember(groupId: group.id, contactId: $0.id) } + .forEach { try! self.dbManager.saveGroupMember($0) } + + // TODO: Add saveBulkGroupMembers to the database + + if let welcome = welcome { + let message = Message( + networkId: nil, + senderId: self.myId, + recipientId: nil, + groupId: group.id, + date: group.createdAt, + status: .received, + isUnread: false, + text: welcome, + replyMessageId: nil, + roundURL: nil, + fileTransferId: nil + ) + + try! self.dbManager.saveMessage(message) + } + + let query = GroupInfo.Query(groupId: group.id) + let info = try! self.dbManager.fetchGroupInfos(query).first + completion(.success(info!)) + case .failure(let error): completion(.failure(error)) } @@ -40,118 +75,137 @@ extension Session { } @discardableResult - func processGroupCreation(_ group: Group, memberIds: [Data], welcome: String?) -> [GroupMember] { - try! dbManager.save(group) - - if let welcome = welcome { - try! dbManager.save(GroupMessage(group: group, text: welcome, me: client.bindings.meMarshalled)) + func processGroupCreation(_ group: Group, memberIds: [Data], welcome: String?) -> GroupInfo { + /// Save the group + /// + _ = try! dbManager.saveGroup(group) + + /// Which of those members are not my friends? + /// + let friendsParticipating = try! dbManager.fetchContacts(Contact.Query(id: Set(memberIds))) + + /// Save the strangers as contacts + /// + let friendIds = friendsParticipating.map(\.id) + memberIds.forEach { + if !friendIds.contains($0) { + try! dbManager.saveContact(.init( + id: $0, + marshaled: nil, + username: nil, + email: nil, + phone: nil, + nickname: nil, + photo: nil, + authStatus: .stranger, + isRecent: false, + createdAt: Date() + )) + } } - var members: [GroupMember] = [] - - if let contactsOnGroup: [Contact] = try? dbManager.fetch(.withUserIds(memberIds)) { - contactsOnGroup.forEach { members.append(GroupMember(contact: $0, group: group)) } + /// Save group members relation + /// + memberIds.forEach { + try! dbManager.saveGroupMember(.init(groupId: group.id, contactId: $0)) } - let strangersOnGroup = memberIds - .filter { !members.map { $0.userId }.contains($0) } - .filter { $0 != client.bindings.myId } - - if !strangersOnGroup.isEmpty { - for stranger in strangersOnGroup.enumerated() { - members.append(GroupMember( - userId: stranger.element, - groupId: group.groupId, - status: .pendingUsername, - username: "Fetching username...", - photo: nil - )) - } + /// Save the welcome message (if any) + /// + if let welcome = welcome { + _ = try! dbManager.saveMessage(.init( + networkId: nil, + senderId: group.leaderId, + recipientId: nil, + groupId: group.id, + date: group.createdAt, + status: .received, + isUnread: true, + text: welcome, + replyMessageId: nil, + roundURL: nil, + fileTransferId: nil + )) } - members.forEach { try! dbManager.save($0) } - if group.leader != client.bindings.meMarshalled, inappnotifications { + if inappnotifications { DeviceFeedback.sound(.contactAdded) DeviceFeedback.shake(.notification) } scanStrangers {} - return members + + let info = try! dbManager.fetchGroupInfos(.init(groupId: group.id)).first + return info! } } // MARK: - GroupMessages extension Session { - public func delete(groupMessages: [Int64]) { - groupMessages.forEach { - do { - try dbManager.delete(GroupMessage.self, .id($0)) - } catch { - log(string: error.localizedDescription, type: .error) - } - } - } - public func send(_ payload: Payload, toGroup group: Group) { - var groupMessage = GroupMessage( - sender: client.bindings.meMarshalled, - groupId: group.groupId, - payload: payload, - unread: false, - timestamp: Date.asTimestamp, - uniqueId: nil, - status: .sending + var message = Message( + senderId: client.bindings.myId, + recipientId: nil, + groupId: group.id, + date: Date(), + status: .sending, + isUnread: false, + text: payload.text, + replyMessageId: payload.reply?.messageId, + roundURL: nil, + fileTransferId: nil ) do { - groupMessage = try dbManager.save(groupMessage) - send(groupMessage: groupMessage) + message = try dbManager.saveMessage(message) + send(groupMessage: message) } catch { log(string: error.localizedDescription, type: .error) } } - public func retryGroupMessage(_ id: Int64) { - guard var message: GroupMessage = try? dbManager.fetch(withId: id) else { return } - message.timestamp = Date.asTimestamp - message.status = .sending - send(groupMessage: try! dbManager.save(message)) - } - - private func send(groupMessage: GroupMessage) { + func send(groupMessage: Message) { guard let manager = client.groupManager else { fatalError("A group manager was not created") } - var groupMessage = groupMessage + var message = groupMessage + + var reply: Reply? + if let replyId = message.replyMessageId, + let replyMessage = try? dbManager.fetchMessages(Message.Query(networkId: replyId)).first { + reply = Reply(messageId: replyId, senderId: replyMessage.senderId) + } + + let payloadData = Payload(text: message.text, reply: reply).asData() DispatchQueue.global().async { [weak self] in guard let self = self else { return } - switch manager.send(groupMessage.payload.asData(), to: groupMessage.groupId) { + switch manager.send(payloadData, to: message.groupId!) { case .success((let roundId, let uniqueId, let roundURL)): - groupMessage.roundURL = roundURL + message.roundURL = roundURL self.client.bindings.listenRound(id: Int(roundId)) { result in switch result { case .success(let succeeded): - groupMessage.uniqueId = uniqueId - groupMessage.status = succeeded ? .sent : .failed + message.networkId = uniqueId + message.status = succeeded ? .sent : .sendingFailed case .failure: - groupMessage.status = .failed + message.status = .sendingFailed } do { - try self.dbManager.save(groupMessage) + try self.dbManager.saveMessage(message) } catch { log(string: error.localizedDescription, type: .error) } } case .failure: - groupMessage.status = .failed + message.status = .sendingFailed } do { - try self.dbManager.save(groupMessage) + try self.dbManager.saveMessage(message) } catch { log(string: error.localizedDescription, type: .error) } @@ -160,77 +214,30 @@ extension Session { public func scanStrangers(_ completion: @escaping () -> Void) { DispatchQueue.global().async { [weak self] in - guard let self = self, let ud = self.client.userDiscovery else { return } - - guard let strangers: [GroupMember] = try? self.dbManager.fetch(.strangers) else { - DispatchQueue.main.async { - completion() - } - - return - } - - let ids = strangers.map { $0.userId } - - var updatedStrangers: [GroupMember] = [] - - ud.lookup(idList: ids) { - switch $0 { - case .success(let contacts): - strangers.forEach { stranger in - if let found = contacts.first(where: { contact in contact.userId == stranger.userId }) { - var updatedStranger = stranger - updatedStranger.status = .usernameSet - updatedStranger.username = found.username - updatedStrangers.append(updatedStranger) - } + guard let self = self, + let ud = self.client.userDiscovery, + let strangers = try? self.dbManager.fetchContacts(.init(username: .some(nil))), + !strangers.isEmpty else { return } + + ud.lookup(idList: strangers.map(\.id)) { result in + switch result { + case .success(let strangersWithUsernames): + let acquaintances = strangers.map { stranger -> Contact in + var exStranger = stranger + exStranger.username = strangersWithUsernames.first(where: { $0.id == stranger.id })?.username + return exStranger } DispatchQueue.main.async { - updatedStrangers.forEach { - do { - try self.dbManager.save($0) - } catch { - log(string: error.localizedDescription, type:.error) - } - } - - log(string: "Scanned unknown group members", type: .info) - completion() + acquaintances.forEach { _ = try? self.dbManager.saveContact($0) } } + + completion() case .failure(let error): - DispatchQueue.main.async { - log(string: error.localizedDescription, type: .error) - completion() - } + print(error.localizedDescription) + DispatchQueue.main.async { completion() } } } } } } - -private extension GroupMessage { - init(group: Group, text: String, me: Data) { - self.init( - sender: group.leader, - groupId: group.groupId, - payload: .init(text: text, reply: nil, attachment: nil), - unread: false, - timestamp: Date.asTimestamp, - uniqueId: nil, - status: group.leader == me ? .sent : .received - ) - } -} - -private extension GroupMember { - init(contact: Contact, group: Group) { - self.init( - userId: contact.userId, - groupId: group.groupId, - status: .usernameSet, - username: contact.username, - photo: contact.photo - ) - } -} diff --git a/Sources/Integration/Session/Session+UD.swift b/Sources/Integration/Session/Session+UD.swift index 82c1e427b6312ae5ab647e3fb2783388ba4368d6..6dd7472fedb8b3c9fd798134d8ede35a29ce3804 100644 --- a/Sources/Integration/Session/Session+UD.swift +++ b/Sources/Integration/Session/Session+UD.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Foundation extension Session { @@ -40,6 +41,19 @@ extension Session { switch $0 { case .success(_): + _ = try? self.dbManager.saveContact(.init( + id: self.client.bindings.myId, + marshaled: self.client.bindings.meMarshalled, + username: value, + email: nil, + phone: nil, + nickname: nil, + photo: nil, + authStatus: .friend, + isRecent: false, + createdAt: Date() + )) + self.username = value completion(.success(nil)) case .failure(let error): diff --git a/Sources/Integration/Session/Session.swift b/Sources/Integration/Session/Session.swift index 2982b295ff964a8eae68c7fd6d58d4ca37788a7c..8a1c20407a7fa24ede9ea75f9468034210dca89f 100644 --- a/Sources/Integration/Session/Session.swift +++ b/Sources/Integration/Session/Session.swift @@ -1,16 +1,17 @@ import Retry +import os.log import Models import Shared import Combine import Defaults -import Database +import XXModels +import XXDatabase import Foundation +import ToastFeature import BackupFeature import NetworkMonitor import DependencyInjection - -import os.log -import ToastFeature +import XXLegacyDatabaseMigrator let logHandler = OSLog(subsystem: "xx.network", category: "Performance debugging") @@ -52,7 +53,7 @@ public final class Session: SessionType { @Dependency var networkMonitor: NetworkMonitoring public let client: Client - public let dbManager: DatabaseManager + public let dbManager: Database private var cancellables = Set<AnyCancellable>() public var myId: Data { client.bindings.myId } @@ -76,61 +77,6 @@ public final class Session: SessionType { networkMonitor.statusPublisher.map { $0 == .available }.eraseToAnyPublisher() } - public func groups(_ request: Group.Request) -> AnyPublisher<[Group], Never> { - self.dbManager - .publisher(fetch: Group.self, request) - .catch { _ in Just([]) } - .eraseToAnyPublisher() - } - - public func groupMembers(_ request: GroupMember.Request) -> AnyPublisher<[GroupMember], Never> { - self.dbManager - .publisher(fetch: GroupMember.self, request) - .catch { _ in Just([]) } - .eraseToAnyPublisher() - } - - lazy public var contacts: (Contact.Request) -> AnyPublisher<[Contact], Never> = { - self.dbManager.publisher(fetch: Contact.self, $0).catch { _ in Just([]) }.eraseToAnyPublisher() - } - - lazy public var singleMessages: (Contact) -> AnyPublisher<[Message], Never> = { - self.dbManager.publisher(fetch: Message.self, .withContact($0.userId)).catch { _ in Just([]) }.eraseToAnyPublisher() - } - - lazy public var groupMessages: (Group) -> AnyPublisher<[GroupMessage], Never> = { - self.dbManager.publisher(fetch: GroupMessage.self, .fromGroup($0.groupId)).catch { _ in Just([]) }.eraseToAnyPublisher() - } - - lazy public var groupChats: (GroupChatInfo.Request) -> AnyPublisher<[GroupChatInfo], Never> = { - self.dbManager.publisher(fetch: GroupChatInfo.self, $0).catch { _ in Just([]) }.eraseToAnyPublisher() - } - - lazy public var singleChats: (SingleChatInfo.Request) -> AnyPublisher<[SingleChatInfo], Never> = { _ in - self.dbManager.publisher(fetch: Contact.self, .friends) - .flatMap { [unowned self] contactList -> AnyPublisher<[SingleChatInfo], Error> in - let contactIds = contactList.map { $0.userId } - - let messagesPublisher: AnyPublisher<[Message], Error> = dbManager - .publisher(fetch: .latestOnesFromContactIds(contactIds)) - .map { $0.sorted(by: { $0.timestamp > $1.timestamp }) } - .eraseToAnyPublisher() - - return messagesPublisher.map { messages -> [SingleChatInfo] in - contactList.map { contact -> SingleChatInfo in - SingleChatInfo(contact: contact, lastMessage: messages.first { - $0.sender == contact.userId || $0.receiver == contact.userId - }) - } - } - .eraseToAnyPublisher() - } - .catch { _ in Just([]) } - .map { $0.filter { $0.lastMessage != nil }} - .map { $0.sorted(by: { $0.lastMessage!.timestamp > $1.lastMessage!.timestamp })} - .eraseToAnyPublisher() - } - public init( passphrase: String, backupFile: Data, @@ -143,7 +89,40 @@ public final class Session: SessionType { os_signpost(.end, log: logHandler, name: "Decrypting", "Finished newClientFromBackup") self.client = client - dbManager = GRDBDatabaseManager() + + let legacyOldPath = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true + )[0].appending("/xxmessenger.sqlite") + + let legacyPath = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("database") + .appendingPathExtension("sqlite").path + + let dbExistsInLegacyOldPath = FileManager.default.fileExists(atPath: legacyOldPath) + let dbExistsInLegacyPath = FileManager.default.fileExists(atPath: legacyPath) + + if dbExistsInLegacyOldPath && !dbExistsInLegacyPath { + try? FileManager.default.moveItem(atPath: legacyOldPath, toPath: legacyPath) + } + + let dbPath = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("xxm_database") + .appendingPathExtension("sqlite").path + + dbManager = try Database.onDisk(path: dbPath) + + if dbExistsInLegacyPath { + try Migrator.live()( + try .init(path: legacyPath), + to: dbManager, + myContactId: client.bindings.myId, + meMarshaled: client.bindings.meMarshalled + ) + + try FileManager.default.moveItem(atPath: legacyPath, toPath: legacyPath.appending("-backup")) + } let report = try! JSONDecoder().decode(BackupReport.self, from: backupData!) @@ -165,30 +144,60 @@ public final class Session: SessionType { public init(ndf: String) throws { let network = try! DependencyInjection.Container.shared.resolve() as XXNetworking self.client = try network.newClient(ndf: ndf) - dbManager = GRDBDatabaseManager() + + let legacyOldPath = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true + )[0].appending("/xxmessenger.sqlite") + + let legacyPath = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("database") + .appendingPathExtension("sqlite").path + + let dbExistsInLegacyOldPath = FileManager.default.fileExists(atPath: legacyOldPath) + let dbExistsInLegacyPath = FileManager.default.fileExists(atPath: legacyPath) + + if dbExistsInLegacyOldPath && !dbExistsInLegacyPath { + try? FileManager.default.moveItem(atPath: legacyOldPath, toPath: legacyPath) + } + + let dbPath = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("xxm_database") + .appendingPathExtension("sqlite").path + + dbManager = try Database.onDisk(path: dbPath) + + if dbExistsInLegacyPath { + try Migrator.live()( + try .init(path: legacyPath), + to: dbManager, + myContactId: client.bindings.myId, + meMarshaled: client.bindings.meMarshalled + ) + + try FileManager.default.moveItem(atPath: legacyPath, toPath: legacyPath.appending("-backup")) + } + try continueInitialization() } private func continueInitialization() throws { - try dbManager.setup() - setupBindings() networkMonitor.start() networkMonitor.statusPublisher .filter { $0 == .available }.first() - .sink { [unowned self] _ in client.bindings.replayRequests() } + .sink { [unowned self] _ in + client.bindings.replayRequests() + scanStrangers {} + } .store(in: &cancellables) registerUnfinishedTransfers() - if let pendingVerificationUsers: [Contact] = try? dbManager.fetch(.verificationInProgress) { - pendingVerificationUsers.forEach { - var contact = $0 - contact.status = .verificationFailed - _ = try? dbManager.save(contact) - } - } + let query = Contact.Query(authStatus: [.verificationInProgress]) + _ = try? dbManager.bulkUpdateContacts(query, .init(authStatus: .verificationFailed)) } public func setDummyTraffic(status: Bool) { @@ -210,7 +219,7 @@ public final class Session: SessionType { guard self.hasRunningTasks == false else { throw NSError.create("") } }.finalCatch { _ in fatalError("Couldn't delete account because network is not stopping") } - dbManager.drop() + try! dbManager.drop() FileManager.xxCleanup() email = nil @@ -230,67 +239,46 @@ public final class Session: SessionType { inappnotifications = true } - public func hideRequestOf(group: Group) { - var group = group - group.status = .hidden - _ = try? dbManager.save(group) - } - - public func hideRequestOf(contact: Contact) { - var contact = contact - contact.status = .hidden - _ = try? dbManager.save(contact) - } - - public func forceFailMessages() { - if let pendingE2E: [Message] = try? dbManager.fetch(.sending) { - pendingE2E.forEach { - var message = $0 - message.status = .failedToSend - _ = try? dbManager.save(message) + private func registerUnfinishedTransfers() { + guard let unfinishedSendingMessages = try? dbManager.fetchMessages(.init(status: [.sending])), + let unfinishedSendingTransfers = try? dbManager.fetchFileTransfers(.init( + id: Set(unfinishedSendingMessages + .filter { $0.fileTransferId != nil } + .compactMap(\.fileTransferId)))) + else { return } + + let pairs = unfinishedSendingMessages.map { message -> (Message, FileTransfer) in + let transfer = unfinishedSendingTransfers.first { ft in + ft.id == message.fileTransferId } - } - if let pendingGroupMessages: [GroupMessage] = try? dbManager.fetch(.sending) { - pendingGroupMessages.forEach { - var message = $0 - message.status = .failed - _ = try? dbManager.save(message) - } + return (message, transfer!) } - } - - private func registerUnfinishedTransfers() { - guard let unfinisheds: [Message] = try? dbManager.fetch(.sendingAttachment), !unfinisheds.isEmpty else { return } - for var message in unfinisheds { - guard let tid = message.payload.attachment?.transferId else { return } + pairs.forEach { message, transfer in + var message = message + var transfer = transfer do { - try client.transferManager?.listenUploadFromTransfer(with: tid) { completed, sent, arrived, total, error in + try client.transferManager?.listenUploadFromTransfer(with: transfer.id) { completed, sent, arrived, total, error in if completed { + transfer.progress = 1.0 message.status = .sent - message.payload.attachment?.progress = 1.0 - if let transfer: FileTransfer = try? self.dbManager.fetch(.withTID(tid)).first { - try? self.dbManager.delete(transfer) - } } else { - if let error = error { - log(string: error.localizedDescription, type: .error) - message.status = .failedToSend + if error != nil { + message.status = .sendingFailed } else { - let progress = Float(arrived)/Float(total) - message.payload.attachment?.progress = progress - return + transfer.progress = Float(arrived)/Float(total) } } - _ = try? self.dbManager.save(message) + _ = try? self.dbManager.saveFileTransfer(transfer) + _ = try? self.dbManager.saveMessage(message) } } catch { - message.status = .sent - _ = try? self.dbManager.save(message) + message.status = .sendingFailed + _ = try? self.dbManager.saveMessage(message) } } } @@ -320,19 +308,23 @@ public final class Session: SessionType { private func setupBindings() { client.requests - .sink { [unowned self] request in - if let _: Contact = try? dbManager.fetch(.withUserId(request.userId)).first { return } + .sink { [unowned self] contact in + let query = Contact.Query(id: [contact.id]) + + if let prexistent = try? dbManager.fetchContacts(query).first { + guard prexistent.authStatus == .stranger else { return } + } if self.inappnotifications { DeviceFeedback.sound(.contactAdded) DeviceFeedback.shake(.notification) } - verify(contact: request) + verify(contact: contact) }.store(in: &cancellables) client.requestsSent - .sink { [unowned self] in _ = try? dbManager.save($0) } + .sink { [unowned self] in _ = try? dbManager.saveContact($0) } .store(in: &cancellables) client.backup @@ -346,8 +338,8 @@ public final class Session: SessionType { /// TODO: Hold a record on the chat that this contact restored. /// var contact = $0 - contact.status = .friend - _ = try? dbManager.save(contact) + contact.authStatus = .friend + _ = try? dbManager.saveContact(contact) }.store(in: &cancellables) backupService.settingsPublisher @@ -375,31 +367,25 @@ public final class Session: SessionType { .sink { print($0) } .store(in: &cancellables) - client.groupMessages - .sink { [unowned self] in _ = try? dbManager.save($0) } - .store(in: &cancellables) - client.messages .sink { [unowned self] in - if var contact: Contact = try? dbManager.fetch(.withUserId($0.sender)).first { + if var contact = try? dbManager.fetchContacts(.init(id: [$0.senderId])).first { contact.isRecent = false - _ = try? dbManager.save(contact) + _ = try? dbManager.saveContact(contact) } - _ = try? dbManager.save($0) + _ = try? dbManager.saveMessage($0) }.store(in: &cancellables) client.network .sink { [unowned self] in networkMonitor.update($0) } .store(in: &cancellables) - client.incomingTransfers - .sink { [unowned self] in handle(incomingTransfer: $0) } - .store(in: &cancellables) - client.groupRequests .sink { [unowned self] request in - if let _: Group = try? dbManager.fetch(.withGroupId(request.0.groupId)).first { return } + if let _ = try? dbManager.fetchGroups(.init(id: [request.0.id])).first { + return + } DispatchQueue.global().async { [weak self] in self?.processGroupCreation(request.0, memberIds: request.1, welcome: request.2) @@ -408,38 +394,42 @@ public final class Session: SessionType { client.confirmations .sink { [unowned self] in - if var contact: Contact = try? dbManager.fetch(.withUserId($0.userId)).first { - contact.status = .friend + if var contact = try? dbManager.fetchContacts(.init(id: [$0.id])).first { + contact.authStatus = .friend contact.isRecent = true contact.createdAt = Date() - _ = try? dbManager.save(contact) + _ = try? dbManager.saveContact(contact) toastController.enqueueToast(model: .init( - title: contact.nickname ?? contact.username, + title: contact.nickname ?? contact.username!, subtitle: Localized.Requests.Confirmations.toaster, leftImage: Asset.sharedSuccess.image )) } }.store(in: &cancellables) - } - public func getTextFromMessage(messageId: Data) -> String? { - guard let message: Message = try? dbManager.fetch(.withUniqueId(messageId)).first else { return nil } - return message.payload.text - } - - public func getTextFromGroupMessage(messageId: Data) -> String? { - guard let message: GroupMessage = try? dbManager.fetch(.withUniqueId(messageId)).first else { return nil } - return message.payload.text - } - - public func getContactWith(userId: Data) -> Contact? { - let contact: Contact? = try? dbManager.fetch(.withUserId(userId)).first - return contact - } - - public func getGroupChatInfoWith(groupId: Data) -> GroupChatInfo? { - let info: GroupChatInfo? = try? dbManager.fetch(.fromGroup(groupId)).first - return info + client.transfers + .sink { [unowned self] in + _ = try? dbManager.saveFileTransfer($0) + + let content = $0.type == "m4a" ? "a voice message" : "an image" + + let message = Message( + networkId: $0.id, + senderId: $0.contactId, + recipientId: myId, + groupId: nil, + date: $0.createdAt, + status: .receiving, + isUnread: true, + text: "Sent you \(content)", + replyMessageId: nil, + roundURL: nil, + fileTransferId: $0.id + ) + + _ = try? dbManager.saveMessage(message) + } + .store(in: &cancellables) } } diff --git a/Sources/Integration/Session/SessionType.swift b/Sources/Integration/Session/SessionType.swift index 121469499c3841d9e1921f14b7f5efdf52a8093e..e99c6f37fb45599aae26836c7a8f248ae20f02f1 100644 --- a/Sources/Integration/Session/SessionType.swift +++ b/Sources/Integration/Session/SessionType.swift @@ -1,6 +1,6 @@ import Models import Combine -import Database +import XXModels import Foundation public protocol SessionType { @@ -10,25 +10,12 @@ public protocol SessionType { var hasRunningTasks: Bool { get } var isOnline: AnyPublisher<Bool, Never> { get } - var contacts: (Contact.Request) -> AnyPublisher<[Contact], Never> { get } - var singleMessages: (Contact) -> AnyPublisher<[Message], Never> { get } - var singleChats: (SingleChatInfo.Request) -> AnyPublisher<[SingleChatInfo], Never> { get } - - func groupMembers(_: GroupMember.Request) -> AnyPublisher<[GroupMember], Never> - - func groups(_: Group.Request) -> AnyPublisher<[Group], Never> - var groupMessages: (Group) -> AnyPublisher<[GroupMessage], Never> { get } - var groupChats: (GroupChatInfo.Request) -> AnyPublisher<[GroupChatInfo], Never> { get } + var dbManager: Database { get } func deleteMyself() throws func getId(from: Data) -> Data? - func forceFailMessages() - - func hideRequestOf(group: Group) - - func hideRequestOf(contact: Contact) - + func sendFile(url: URL, to: Contact) func send(imageData: Data, to: Contact, completion: @escaping (Result<Void, Error>) -> Void) func verify(contact: Contact) @@ -55,27 +42,13 @@ public protocol SessionType { // Messages - func readAll(from: Group) - func readAll(from: Contact) func retryMessage(_: Int64) - func retryGroupMessage(_: Int64) - func deleteAll(from: Group) - func deleteAll(from: Contact) - func delete(messages: [Int64]) - func delete(groupMessages: [Int64]) func send(_: Payload, toContact: Contact) - func getTextFromMessage(messageId: Data) -> String? - func getTextFromGroupMessage(messageId: Data) -> String? - // Contacts - func update(_: Contact) func add(_: Contact) throws func confirm(_: Contact) throws - func find(by: String) -> Contact? - func delete<T: Persistable>(_: T, isRequest: Bool) - func deleteContact(_: Contact) throws func retryRequest(_: Contact) throws @@ -91,9 +64,6 @@ public protocol SessionType { name: String, welcome: String?, members: [Contact], - _ completion: @escaping (Result<(Group, [GroupMember]), Error>) -> Void + _ completion: @escaping (Result<GroupInfo, Error>) -> Void ) - - func getContactWith(userId: Data) -> Contact? - func getGroupChatInfoWith(groupId: Data) -> GroupChatInfo? } diff --git a/Sources/LaunchFeature/LaunchCoordinator.swift b/Sources/LaunchFeature/LaunchCoordinator.swift index 2f446810a29e5639190477705ffd8225bb88eb37..4f5a56291b19634df4c46edc2634acb55ba5812e 100644 --- a/Sources/LaunchFeature/LaunchCoordinator.swift +++ b/Sources/LaunchFeature/LaunchCoordinator.swift @@ -1,5 +1,6 @@ import UIKit import Models +import XXModels import Presentation public protocol LaunchCoordinating { @@ -7,7 +8,7 @@ public protocol LaunchCoordinating { func toRequests(from: UIViewController) func toOnboarding(with: String, from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) - func toGroupChat(with: GroupChatInfo, from: UIViewController) + func toGroupChat(with: GroupInfo, from: UIViewController) } public struct LaunchCoordinator: LaunchCoordinating { @@ -17,14 +18,14 @@ public struct LaunchCoordinator: LaunchCoordinating { var chatListFactory: () -> UIViewController var onboardingFactory: (String) -> UIViewController var singleChatFactory: (Contact) -> UIViewController - var groupChatFactory: (GroupChatInfo) -> UIViewController + var groupChatFactory: (GroupInfo) -> UIViewController public init( requestsFactory: @escaping () -> UIViewController, chatListFactory: @escaping () -> UIViewController, onboardingFactory: @escaping (String) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, - groupChatFactory: @escaping (GroupChatInfo) -> UIViewController + groupChatFactory: @escaping (GroupInfo) -> UIViewController ) { self.requestsFactory = requestsFactory self.chatListFactory = chatListFactory @@ -56,7 +57,7 @@ public extension LaunchCoordinator { replacePresenter.present(chatListScreen, singleChatScreen, from: parent) } - func toGroupChat(with group: GroupChatInfo, from parent: UIViewController) { + func toGroupChat(with group: GroupInfo, from parent: UIViewController) { let chatListScreen = chatListFactory() let groupChatScreen = groupChatFactory(group) replacePresenter.present(chatListScreen, groupChatScreen, from: parent) diff --git a/Sources/LaunchFeature/LaunchViewModel.swift b/Sources/LaunchFeature/LaunchViewModel.swift index 5e1b78a61a04e567242a2b058f7d5afa872bfb7f..054432b4403e7177bf129c21680a58ac4abb6b12 100644 --- a/Sources/LaunchFeature/LaunchViewModel.swift +++ b/Sources/LaunchFeature/LaunchViewModel.swift @@ -1,8 +1,10 @@ import HUD import Shared import Models + import Combine import Defaults +import XXModels import Foundation import Integration import Permissions @@ -119,16 +121,16 @@ final class LaunchViewModel { func getContactWith(userId: Data) -> Contact? { guard let session = try? DependencyInjection.Container.shared.resolve() as SessionType, - let contact = session.getContactWith(userId: userId) else { + let contact = try? session.dbManager.fetchContacts(.init(id: [userId])).first else { return nil } return contact } - func getGroupInfoWith(groupId: Data) -> GroupChatInfo? { + func getGroupInfoWith(groupId: Data) -> GroupInfo? { guard let session: SessionType = try? DependencyInjection.Container.shared.resolve(), - let info = session.getGroupChatInfoWith(groupId: groupId) else { + let info = try? session.dbManager.fetchGroupInfos(.init(groupId: groupId)).first else { return nil } diff --git a/Sources/MenuFeature/ViewModels/MenuViewModel.swift b/Sources/MenuFeature/ViewModels/MenuViewModel.swift index 92eb785136c7cc4ba6ff6fb30fc597d93d6a3ac7..f3e4bcbd5260af8c2e62849c7eca05b4098cffe4 100644 --- a/Sources/MenuFeature/ViewModels/MenuViewModel.swift +++ b/Sources/MenuFeature/ViewModels/MenuViewModel.swift @@ -1,4 +1,5 @@ import Combine +import XXModels import Defaults import Foundation import Integration @@ -11,24 +12,21 @@ final class MenuViewModel { @KeyObject(.username, defaultValue: "") var username: String var requestCount: AnyPublisher<Int, Never> { - Publishers.CombineLatest( - session.contacts(.received), - session.groups(.pending) - ).map { (contacts, groups) in - let contactRequests = contacts.filter { - $0.status == .verified || - $0.status == .confirming || - $0.status == .confirmationFailed || - $0.status == .verificationFailed || - $0.status == .verificationInProgress - } - - let groupRequests = groups.filter { - $0.status == .pending - } - - return contactRequests.count + groupRequests.count - }.eraseToAnyPublisher() + let groupQuery = Group.Query(authStatus: [.pending]) + let contactsQuery = Contact.Query(authStatus: [ + .verified, + .confirming, + .confirmationFailed, + .verificationFailed, + .verificationInProgress + ]) + + return Publishers.CombineLatest( + session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure(), + session.dbManager.fetchGroupsPublisher(groupQuery).assertNoFailure() + ) + .map { $0.0.count + $0.1.count } + .eraseToAnyPublisher() } var xxdk: String { diff --git a/Sources/Models/Attachment.swift b/Sources/Models/Attachment.swift deleted file mode 100644 index 92eb3c09dcf4a7846bf5e265ec5a5080342a69b1..0000000000000000000000000000000000000000 --- a/Sources/Models/Attachment.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation - -public struct Attachment: Codable, Equatable, Hashable { - - public enum Extension: Int64, Codable, CaseIterable { - case image - case audio - - public static func from(_ string: String) -> Extension? { - self.allCases.first{ $0.written == string } - } - - public var written: String { - switch self { - case .image: - return "jpeg" - case .audio: - return "m4a" - } - } - - public var writtenExtended: String { - switch self { - case .image: - return "image" - case .audio: - return "voice message" - } - } - } - - public let data: Data? - public let name: String - public var transferId: Data? - public let _extension: Extension - public var progress: Float = 0.0 - - public init( - name: String, - data: Data? = nil, - transferId: Data? = nil, - _extension: Extension - ) { - self.data = data - self.name = name - self._extension = _extension - self.transferId = transferId - } -} diff --git a/Sources/Models/Contact.swift b/Sources/Models/Contact.swift deleted file mode 100644 index 1959743eb66d6888db0d7c65e07f5cfd0e11826a..0000000000000000000000000000000000000000 --- a/Sources/Models/Contact.swift +++ /dev/null @@ -1,123 +0,0 @@ -import UIKit -import DifferenceKit - -public protocol IndexableItem { - var indexedOn: NSString { get } -} - -public class IndexedListCollator<Item: IndexableItem> { - private final class CollationWrapper: NSObject { - let value: Any - @objc let indexedOn: NSString - - init(value: Any, indexedOn: NSString) { - self.value = value - self.indexedOn = indexedOn - } - - func unwrappedValue<UnwrappedType>() -> UnwrappedType { - return value as! UnwrappedType - } - } - - public init() {} - - public func sectioned(items: [Item]) -> (sections: [[Item]], collation: UILocalizedIndexedCollation) { - let collation = UILocalizedIndexedCollation.current() - let selector = #selector(getter: CollationWrapper.indexedOn) - - let wrappedItems = items.map { item in - CollationWrapper(value: item, indexedOn: item.indexedOn) - } - - let sortedObjects = collation.sortedArray(from: wrappedItems, collationStringSelector: selector) as! [CollationWrapper] - - var sections = collation.sectionIndexTitles.map { _ in [Item]() } - sortedObjects.forEach { item in - let sectionNumber = collation.section(for: item, collationStringSelector: selector) - sections[sectionNumber].append(item.unwrappedValue()) - } - - return (sections: sections.filter { !$0.isEmpty }, collation: collation) - } -} - -public struct Contact: Codable, Hashable, Equatable { - public enum Request { - case all - case failed - case friends - case received - case requested - case isRecent - case verificationInProgress - case withUserId(Data) - case withUserIds([Data]) - case withUsername(String) - } - - public enum Status: Int64, Codable { - case friend - case stranger - case verified - case verificationFailed - case verificationInProgress - case requested - case requesting - case requestFailed - case confirming - case confirmationFailed - case hidden - } - - public var id: Int64? - public var photo: Data? - public let userId: Data - public var email: String? - public var phone: String? - public var status: Status - public var marshaled: Data - public var createdAt: Date - public let username: String - public var nickname: String? - public var isRecent: Bool - - public init( - photo: Data?, - userId: Data, - email: String?, - phone: String?, - status: Status, - marshaled: Data, - username: String, - nickname: String?, - createdAt: Date, - isRecent: Bool - ) { - self.email = email - self.phone = phone - self.photo = photo - self.status = status - self.userId = userId - self.username = username - self.nickname = nickname - self.marshaled = marshaled - self.createdAt = createdAt - self.isRecent = isRecent - } - - public var differenceIdentifier: Data { userId } - - public static var databaseTableName: String { "contacts" } -} - -extension Contact: Differentiable {} -extension Contact: IndexableItem { - public var indexedOn: NSString { - guard let nickname = nickname else { - return "\(username.first!)" as NSString - } - - return "\(nickname.first!)" as NSString - } -} diff --git a/Sources/Models/FileTransfer.swift b/Sources/Models/FileTransfer.swift deleted file mode 100644 index 79fac5d1b0db78480c766a546b695ad426c8ae68..0000000000000000000000000000000000000000 --- a/Sources/Models/FileTransfer.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -public struct FileTransfer { - public enum Request { - case withTID(Data) - case withContactId(Data) - } - - public var tid: Data - public var id: Int64? - public var contact: Data - public var fileName: String - public var fileType: String - public var isIncoming: Bool - - public static var databaseTableName: String { "transfers" } - - public init( - id: Int64? = nil, - tid: Data, - contact: Data, - fileName: String, - fileType: String, - isIncoming: Bool - ) { - self.id = id - self.tid = tid - self.contact = contact - self.fileName = fileName - self.fileType = fileType - self.isIncoming = isIncoming - } -} - -extension FileTransfer: Codable {} -extension FileTransfer: Hashable {} -extension FileTransfer: Equatable {} diff --git a/Sources/Models/Group.swift b/Sources/Models/Group.swift deleted file mode 100644 index feda834cb266a933e2470e3d6ab5910b22d4588b..0000000000000000000000000000000000000000 --- a/Sources/Models/Group.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import KeychainAccess - -public struct Group: Codable, Equatable, Hashable { - public enum Status: Int64, Codable { - case hidden - case pending - case deleting - case participating - } - - public enum Request { - case pending - case accepted - case withGroupId(Data) - } - - public var id: Int64? - public var name: String - public var leader: Data - public var groupId: Data - public var status: Status - public var serialize: Data - public var createdAt: Date - public static var databaseTableName: String { "groups" } - - public init( - leader: Data, - name: String, - groupId: Data, - status: Status, - createdAt: Date, - serialize: Data - ) { - self.name = name - self.leader = leader - self.status = status - self.groupId = groupId - self.createdAt = createdAt - self.serialize = serialize - } -} diff --git a/Sources/Models/GroupChatInfo.swift b/Sources/Models/GroupChatInfo.swift deleted file mode 100644 index 9b39ff6dbbd1cbad8202bd8117adfb9fc606b2c3..0000000000000000000000000000000000000000 --- a/Sources/Models/GroupChatInfo.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -public struct GroupChatInfo: Codable, Equatable, Hashable { - public enum Request { - case accepted - case fromGroup(Data) - } - - public var group: Group - public var members: [GroupMember] - public var lastMessage: GroupMessage? - - public init( - group: Group, - members: [GroupMember], - lastMessage: GroupMessage? = nil - ) { - self.group = group - self.members = members - self.lastMessage = lastMessage - } -} diff --git a/Sources/Models/GroupMember.swift b/Sources/Models/GroupMember.swift deleted file mode 100644 index 25c619f3280d9aa2891ab7b205e6639884783840..0000000000000000000000000000000000000000 --- a/Sources/Models/GroupMember.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -public struct GroupMember { - public enum Request { - case all - case strangers - case fromGroup(Data) - case withUserId(Data) - } - - public enum Status: Int64, Codable { - case usernameSet - case pendingUsername - } - - public var id: Int64? - public var userId: Data - public var groupId: Data - public var status: Status - public var username: String - public var photo: Data? - - public init( - id: Int64? = nil, - userId: Data, - groupId: Data, - status: Status, - username: String, - photo: Data? = nil - ) { - self.id = id - self.userId = userId - self.groupId = groupId - self.username = username - self.status = status - self.photo = photo - } -} - -extension GroupMember: Codable {} -extension GroupMember: Hashable {} -extension GroupMember: Equatable {} diff --git a/Sources/Models/GroupMessage.swift b/Sources/Models/GroupMessage.swift deleted file mode 100644 index ffdb6df473f279bb68bc9e418963a4c5b2f79405..0000000000000000000000000000000000000000 --- a/Sources/Models/GroupMessage.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation - -public struct GroupMessage: Codable, Equatable, Hashable { - public enum Request { - case withUniqueId(Data) - case id(Int64) - case sending - case fromGroup(Data) - case unreadsFromGroup(Data) - } - - public static var databaseTableName: String { "groupMessages" } - - public enum Status: Int64, Codable { - case sent - case read - case failed - case sending - case received - } - - public var id: Int64? - public var uniqueId: Data? - public var groupId: Data - public var sender: Data - public var roundId: Int64? - public var payload: Payload - public var status: Status - public var roundURL: String? - public var unread: Bool - public var timestamp: Int - - public init( - id: Int64? = nil, - sender: Data, - groupId: Data, - payload: Payload, - unread: Bool, - timestamp: Int = 0, - uniqueId: Data?, - status: Status, - roundId: Int64? = nil, - roundURL: String? = nil - ) { - self.id = id - self.sender = sender - self.groupId = groupId - self.payload = payload - self.unread = unread - self.timestamp = timestamp - self.uniqueId = uniqueId - self.status = status - self.roundId = roundId - self.roundURL = roundURL - } -} diff --git a/Sources/Models/Message.swift b/Sources/Models/Message.swift deleted file mode 100644 index fec18587bfb4f9b185ceb986af6ca5329a31e451..0000000000000000000000000000000000000000 --- a/Sources/Models/Message.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import DifferenceKit - -public struct Message: Codable, Equatable, Hashable { - public enum Request { - case sending - case withUniqueId(Data) - case withId(Int64) - case sendingAttachment - case withContact(Data) - case unreadsFromContactId(Data) - case latestOnesFromContactIds([Data]) - } - - public enum Status: Int64, Codable { - case read - case sent - case sending - case sendingAttachment - case receivingAttachment - case received - case failedToSend - case timedOut - } - - public var id: Int64? - public var unread: Bool - public let sender: Data - public var roundURL: String? - public var report: Data? - public var status: Status - public let receiver: Data - public var timestamp: Int - public var uniqueId: Data? - public var payload: Payload - public static var databaseTableName: String { "messages" } - - public init ( - sender: Data, - receiver: Data, - payload: Payload, - unread: Bool, - timestamp: Int, - uniqueId: Data?, - status: Status, - roundURL: String? = nil - ) { - self.sender = sender - self.unread = unread - self.status = status - self.payload = payload - self.receiver = receiver - self.uniqueId = uniqueId - self.timestamp = timestamp - self.roundURL = roundURL - } -} - -public extension Message.Status { - var canReply: Bool { - switch self { - case .sent, .received, .read: - return true - default: - return false - } - } -} - -extension Message: Differentiable {} diff --git a/Sources/Models/Payload.swift b/Sources/Models/Payload.swift index 4fda3b78993e049b6ef7d6986411fac1ba161f34..8e6f519b5ca09531b97961d1616a63477325d002 100644 --- a/Sources/Models/Payload.swift +++ b/Sources/Models/Payload.swift @@ -3,12 +3,10 @@ import Foundation public struct Payload: Codable, Equatable, Hashable { public var text: String public var reply: Reply? - public var attachment: Attachment? - public init(text: String, reply: Reply?, attachment: Attachment?) { + public init(text: String, reply: Reply?) { self.text = text self.reply = reply - self.attachment = attachment } public init(with marshaled: Data) throws { @@ -23,7 +21,7 @@ public struct Payload: Codable, Equatable, Hashable { ) } - self.init(text: proto.text, reply: reply, attachment: nil) + self.init(text: proto.text, reply: reply) } public func asData() -> Data { diff --git a/Sources/Models/SingleChatInfo.swift b/Sources/Models/SingleChatInfo.swift deleted file mode 100644 index 21c7f7d28bb4fb98b32c4239101cc8308b4be6ae..0000000000000000000000000000000000000000 --- a/Sources/Models/SingleChatInfo.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -public struct SingleChatInfo: Codable, Equatable, Hashable { - public enum Request { - case all - } - - public var contact: Contact - public var lastMessage: Message? - - public init( - contact: Contact, - lastMessage: Message? - ) { - self.contact = contact - self.lastMessage = lastMessage - } -} diff --git a/Sources/PushFeature/PushHandler.swift b/Sources/PushFeature/PushHandler.swift index bb0b19cd27060c2bf475ba948acf0333debc388e..f750c575899229ba5f6212261197087755dc8442 100644 --- a/Sources/PushFeature/PushHandler.swift +++ b/Sources/PushFeature/PushHandler.swift @@ -1,7 +1,7 @@ import UIKit import Models import Defaults -import Database +import XXModels import Integration import DependencyInjection @@ -103,15 +103,19 @@ public final class PushHandler: PushHandling { return } - let dbManager = GRDBDatabaseManager() - try? dbManager.setup() + let dbPath = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("xxm_database") + .appendingPathExtension("sqlite").path let tuples: [(String, Push)] = pushes.compactMap { - guard let userId = $0.source, let contact: Contact = try? dbManager.fetch(.withUserId(userId)).first else { + guard let userId = $0.source, + let dbManager = try? Database.onDisk(path: dbPath), + let contact = try? dbManager.fetchContacts(.init(id: [userId])).first else { return ($0.type.unknownSenderContent!, $0) } - let name = contact.nickname ?? contact.username + let name = (contact.nickname ?? contact.username) ?? "" return ($0.type.knownSenderContent(name)!, $0) } diff --git a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift index c92d5cb26e82317750b6b039bad65c563c1f7eea..5edb77726fa6d43ba7d4e2a816192fcadd39303b 100644 --- a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift @@ -3,6 +3,7 @@ import UIKit import Models import Shared import Combine +import XXModels import Countries import ToastFeature import DrawerFeature @@ -110,10 +111,10 @@ extension RequestsReceivedController: UICollectionViewDelegate { switch request { case .group(let group): - guard group.status == .pending || group.status == .hidden else { return } + guard group.authStatus == .pending || group.authStatus == .hidden else { return } presentGroupRequestDrawer(forGroup: group) case .contact(let contact): - guard contact.status == .verified || contact.status == .hidden else { return } + guard contact.authStatus == .verified || contact.authStatus == .hidden else { return } presentSingleRequestDrawer(forContact: contact) } } @@ -211,7 +212,7 @@ extension RequestsReceivedController { let drawerNickname = DrawerText( font: Fonts.Mulish.extraBold.font(size: 26.0), - text: contact.nickname ?? contact.username, + text: (contact.nickname ?? contact.username) ?? "", color: Asset.neutralDark.color, spacingAfter: 20 ) @@ -392,7 +393,7 @@ extension RequestsReceivedController { let drawerUsername = DrawerText( font: Fonts.Mulish.extraBold.font(size: 26.0), - text: contact.username, + text: contact.username ?? "", color: Asset.neutralDark.color, spacingAfter: 25 ) @@ -452,7 +453,7 @@ extension RequestsReceivedController { items.append(drawerNicknameTitle) let drawerNicknameInput = DrawerInput( - placeholder: contact.username, + placeholder: contact.username ?? "", validator: .init( wrongIcon: .image(Asset.sharedError.image), correctIcon: .image(Asset.sharedSuccess.image), diff --git a/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift b/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift index 4d28c6bf7fc7c8f482a8b69809b679ec9b447816..3c798cfc0243bffbbf54939c37d7ea7eb90c1294 100644 --- a/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift +++ b/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Models +import XXModels import MenuFeature import Presentation import ContactFeature @@ -11,8 +12,8 @@ public protocol RequestsCoordinating { func toSideMenu(from: UIViewController) func toContact(_: Contact, from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) + func toGroupChat(with: GroupInfo, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) - func toGroupChat(with: GroupChatInfo, from: UIViewController) func toDrawerBottom(_: UIViewController, from: UIViewController) func toNickname(from: UIViewController, prefilled: String, _: @escaping StringClosure) } @@ -26,7 +27,7 @@ public struct RequestsCoordinator: RequestsCoordinating { var searchFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController var singleChatFactory: (Contact) -> UIViewController - var groupChatFactory: (GroupChatInfo) -> UIViewController + var groupChatFactory: (GroupInfo) -> UIViewController var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController var nicknameFactory: (String, @escaping StringClosure) -> UIViewController @@ -34,7 +35,7 @@ public struct RequestsCoordinator: RequestsCoordinating { searchFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, - groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, + groupChatFactory: @escaping (GroupInfo) -> UIViewController, sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController, nicknameFactory: @escaping (String, @escaping StringClosure) -> UIViewController ) { @@ -57,7 +58,7 @@ public extension RequestsCoordinator { } func toGroupChat( - with info: GroupChatInfo, + with info: GroupInfo, from parent: UIViewController ) { let screen = groupChatFactory(info) diff --git a/Sources/RequestsFeature/Models/Request.swift b/Sources/RequestsFeature/Models/Request.swift index 2bf872c5a02815db8be3eb0adf92790328bf0082..595410d43257294a139a0f7b77a717992ed6ffcf 100644 --- a/Sources/RequestsFeature/Models/Request.swift +++ b/Sources/RequestsFeature/Models/Request.swift @@ -1,4 +1,5 @@ import Models +import XXModels import Foundation enum Section: Int { @@ -15,16 +16,16 @@ enum Request: Hashable, Equatable { case .group: return .verified case .contact(let contact): - return contact.status.toRequestStatus() + return contact.authStatus.toRequestStatus() } } var id: Data { switch self { case .group(let group): - return group.groupId + return group.id case .contact(let contact): - return contact.userId + return contact.id } } } @@ -40,7 +41,7 @@ enum RequestStatus { case failedToRequest } -extension Contact.Status { +extension Contact.AuthStatus { func toRequestStatus() -> RequestStatus { switch self { case .friend, .stranger: diff --git a/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift index f67882bb3fca1237ac3f138b549d262755e7fafe..9214c9ef3f19d337ae66f0f9a6c8368741ff1096 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift @@ -24,7 +24,8 @@ final class RequestsFailedViewModel { var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() init() { - session.contacts(.failed) + session.dbManager.fetchContactsPublisher(.init(authStatus: [.requestFailed])) + .assertNoFailure() .map { data -> NSDiffableDataSourceSnapshot<Section, Request> in var snapshot = NSDiffableDataSourceSnapshot<Section, Request>() snapshot.appendSections([.appearing]) diff --git a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift index 35c2589da673e10cbc6dac35eec8771ead6a309b..7dd9e8c1d07c9b15d9829803be59c55c0955fd12 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift @@ -4,8 +4,9 @@ import Models import Shared import Combine import Defaults -import DrawerFeature +import XXModels import Integration +import DrawerFeature import CombineSchedulers import DependencyInjection @@ -47,51 +48,61 @@ final class RequestsReceivedViewModel { private let groupConfirmationSubject = PassthroughSubject<Group, Never>() private let contactConfirmationSubject = PassthroughSubject<Contact, Never>() private let itemsSubject = CurrentValueSubject<NSDiffableDataSourceSnapshot<Section, RequestReceived>, Never>(.init()) - private var groupChats = [GroupChatInfo]() var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() init() { - Publishers.CombineLatest4( - session.groups(.pending), - session.contacts(.all), - session.groupMembers(.all), + let groupsQuery = Group.Query( + authStatus: [ + .hidden, + .pending + ]) + + let contactsQuery = Contact.Query( + authStatus: [ + .friend, + .hidden, + .verified, + .verificationFailed, + .verificationInProgress + ]) + + let groupStream = session.dbManager.fetchGroupsPublisher(groupsQuery).assertNoFailure() + let contactsStream = session.dbManager.fetchContactsPublisher(contactsQuery).assertNoFailure() + + Publishers.CombineLatest3( + groupStream, + contactsStream, updateSubject.eraseToAnyPublisher() ) .subscribe(on: DispatchQueue.main) .receive(on: DispatchQueue.global()) .map { [unowned self] data -> NSDiffableDataSourceSnapshot<Section, RequestReceived> in - let contactRequests = data.1.filter { - $0.status == .hidden || - $0.status == .verified || - $0.status == .verificationFailed || - $0.status == .verificationInProgress - } - var snapshot = NSDiffableDataSourceSnapshot<Section, RequestReceived>() snapshot.appendSections([.appearing, .hidden]) - let requests = data.0.map(Request.group) + contactRequests.map(Request.contact) + let contactsFilteringFriends = data.1.filter { $0.authStatus != .friend } + let requests = data.0.map(Request.group) + contactsFilteringFriends.map(Request.contact) let receivedRequests = requests.map { request -> RequestReceived in switch request { case let .group(group): - var leaderTitle = "" - - if let leader = data.1.first(where: { $0.userId == group.leader }) { - leaderTitle = leader.nickname ?? leader.username - } else if let leader = data.2.first(where: { $0.userId == group.leader }) { - leaderTitle = leader.username + func leaderName() -> String { + if let leader = data.1.first(where: { $0.id == group.leaderId }) { + return (leader.nickname ?? leader.username) ?? "Leader is not a friend" + } else { + return "[Error retrieving leader]" + } } return RequestReceived( request: request, - isHidden: group.status == .hidden, - leader: leaderTitle + isHidden: group.authStatus == .hidden, + leader: leaderName() ) case let .contact(contact): return RequestReceived( request: request, - isHidden: contact.status == .hidden, + isHidden: contact.authStatus == .hidden, leader: nil ) } @@ -112,10 +123,6 @@ final class RequestsReceivedViewModel { receiveCompletion: { _ in }, receiveValue: { [unowned self] in itemsSubject.send($0) } ).store(in: &cancellables) - - session.groupChats(.accepted) - .sink { [unowned self] in groupChats = $0 } - .store(in: &cancellables) } func didToggleHiddenRequestsSwitcher() { @@ -136,7 +143,10 @@ final class RequestsReceivedViewModel { } func didRequestHide(group: Group) { - session.hideRequestOf(group: group) + if var group = try? session.dbManager.fetchGroups(.init(id: [group.id])).first { + group.authStatus = .hidden + _ = try? session.dbManager.saveGroup(group) + } } func didRequestAccept(group: Group) { @@ -157,52 +167,44 @@ final class RequestsReceivedViewModel { _ group: Group, _ completion: @escaping (Result<[DrawerTableCellModel], Error>) -> Void ) { - session.scanStrangers { [weak self] in - guard let self = self else { return } - - Publishers.CombineLatest( - self.session.contacts(.all), - self.session.groupMembers(.fromGroup(group.groupId)) - ) - .sink { (allContacts, groupMembers) in - - guard !groupMembers.map(\.status).contains(.pendingUsername) else { - completion(.failure(NSError.create(""))) // Some members are still pending username lookup... - return - } - - // Now that all members are set with their usernames lets find our friends: - // - let contactsAlsoMembers = allContacts.filter { groupMembers.map(\.userId).contains($0.userId) } - let membersNonContacts = groupMembers.filter { !contactsAlsoMembers.map(\.userId).contains($0.userId) } - - var models = [DrawerTableCellModel]() - - contactsAlsoMembers.forEach { - models.append(.init( - title: $0.nickname ?? $0.username, - image: $0.photo, - isCreator: $0.userId == group.leader, - isConnection: true - )) - } - - membersNonContacts.forEach { - models.append(.init( - title: $0.username, - image: nil, - isCreator: $0.userId == group.leader, - isConnection: false - )) - } - - completion(.success(models)) - }.store(in: &self.cancellables) + if let info = try? session.dbManager.fetchGroupInfos(.init(groupId: group.id)).first { + session.dbManager.fetchContactsPublisher(.init(id: Set(info.members.map(\.id)))) + .assertNoFailure() + .sink { members in + let withUsername = members + .filter { $0.username != nil } + .map { + DrawerTableCellModel( + id: $0.id, + title: $0.nickname ?? $0.username!, + image: $0.photo, + isCreator: $0.id == group.leaderId, + isConnection: $0.authStatus == .friend + ) + } + + let withoutUsername = members + .filter { $0.username == nil } + .map { + DrawerTableCellModel( + id: $0.id, + title: "Fetching username...", + image: $0.photo, + isCreator: $0.id == group.leaderId, + isConnection: $0.authStatus == .friend + ) + } + + completion(.success(withUsername + withoutUsername)) + }.store(in: &cancellables) } } func didRequestHide(contact: Contact) { - session.hideRequestOf(contact: contact) + if var contact = try? session.dbManager.fetchContacts(.init(id: [contact.id])).first { + contact.authStatus = .hidden + _ = try? session.dbManager.saveContact(contact) + } } func didRequestAccept(contact: Contact, nickname: String? = nil) { @@ -222,8 +224,11 @@ final class RequestsReceivedViewModel { } } - func groupChatWith(group: Group) -> GroupChatInfo { - guard let info = groupChats.first(where: { $0.group.groupId == group.groupId }) else { fatalError() } + func groupChatWith(group: Group) -> GroupInfo { + guard let info = try? session.dbManager.fetchGroupInfos(.init(groupId: group.id)).first else { + fatalError() + } + return info } } diff --git a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift index ebead6be165ce126a66b729321eac1fe8135fbbb..3789428ffa0a9539e35b3344fdd820ee896d03a2 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift @@ -32,7 +32,8 @@ final class RequestsSentViewModel { var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() init() { - session.contacts(.requested) + session.dbManager.fetchContactsPublisher(.init(authStatus: [.requested])) + .assertNoFailure() .removeDuplicates() .map { data -> NSDiffableDataSourceSnapshot<Section, RequestSent> in var snapshot = NSDiffableDataSourceSnapshot<Section, RequestSent>() diff --git a/Sources/RequestsFeature/Views/RequestCell.swift b/Sources/RequestsFeature/Views/RequestCell.swift index 5a6bd6db7f9845d54745521bc9f49fd2c8dc753e..e0af9ec6017ef1241ed0831ee0dbad71af7dc3d4 100644 --- a/Sources/RequestsFeature/Views/RequestCell.swift +++ b/Sources/RequestsFeature/Views/RequestCell.swift @@ -89,7 +89,7 @@ final class RequestCell: UICollectionViewCell { } setupContact( - title: contact.nickname ?? contact.username, + title: (contact.nickname ?? contact.username) ?? "", photo: contact.photo, phone: phone, email: contact.email, @@ -128,7 +128,7 @@ final class RequestCell: UICollectionViewCell { } setupContact( - title: contact.nickname ?? contact.username, + title: (contact.nickname ?? contact.username) ?? "", photo: contact.photo, phone: phone, email: contact.email, @@ -165,7 +165,7 @@ final class RequestCell: UICollectionViewCell { } setupContact( - title: contact.nickname ?? contact.username, + title: (contact.nickname ?? contact.username) ?? "", photo: contact.photo, phone: phone, email: contact.email, diff --git a/Sources/RestoreFeature/Service/RestoreService.swift b/Sources/RestoreFeature/Service/RestoreService.swift deleted file mode 100644 index 9bd5b80e169d75d89806e716116b4781a080f691..0000000000000000000000000000000000000000 --- a/Sources/RestoreFeature/Service/RestoreService.swift +++ /dev/null @@ -1,38 +0,0 @@ -//import UIKit -//import Models -//import Combine -// -//import DependencyInjection -// -//public struct RestoreService: RestoreServiceType { -// -// -// -// @Dependency private var coordinator: RestoreCoordinating -// -// public var inProgress: AnyPublisher<Void, Never> { inProgressSubject.eraseToAnyPublisher() } -// public var settings: AnyPublisher<RestoreSettings, Never> { settingsSubject.eraseToAnyPublisher() } -// -// private let inProgressSubject = PassthroughSubject<Void, Never>() -// private let settingsSubject = PassthroughSubject<RestoreSettings, Never>() -// -// private var cancellables = Set<AnyCancellable>() -// -// public init() {} -// -// public func authorize(service: CloudService, from controller: UIViewController) { -// } -// } -// -// public func download( -// from settings: RestoreSettings, -// progress: @escaping RestoreProgress, -// whenFinished: @escaping RestoreDownloadFinished -// ) { -// drive.downloadBackup( -// settings.backup!.id, -// progressCallback: progress, -// whenFinished -// ) -// } -//} diff --git a/Sources/ScanFeature/Coordinator/ScanCoordinator.swift b/Sources/ScanFeature/Coordinator/ScanCoordinator.swift index 6a36db1801d9874f687a3eef13dabf657125f943..98e605775ccdf54b5ffda4b0a6ad0183a5c15fe1 100644 --- a/Sources/ScanFeature/Coordinator/ScanCoordinator.swift +++ b/Sources/ScanFeature/Coordinator/ScanCoordinator.swift @@ -1,5 +1,6 @@ import UIKit import Models +import XXModels import MenuFeature import Presentation import ContactFeature diff --git a/Sources/ScanFeature/ViewModels/ScanViewModel.swift b/Sources/ScanFeature/ViewModels/ScanViewModel.swift index c3f51841e1726407c69f47de05f15570f01c438f..b940226d75390dc33a79118d44ca74ca4cbe3240 100644 --- a/Sources/ScanFeature/ViewModels/ScanViewModel.swift +++ b/Sources/ScanFeature/ViewModels/ScanViewModel.swift @@ -1,6 +1,7 @@ import Shared import Models import Combine +import XXModels import Foundation import Integration import CombineSchedulers @@ -53,10 +54,12 @@ final class ScanViewModel { return } - if let previouslyAdded = self.session.find(by: usernameAndId.0) { + + + if let previouslyAdded = try? self.session.dbManager.fetchContacts(.init(id: [usernameAndId.1])).first { var error = ScanError.unknown(Localized.Scan.Error.general) - switch previouslyAdded.status { + switch previouslyAdded.authStatus { case .friend: error = .alreadyFriends(usernameAndId.0) case .requested, .verified: @@ -70,16 +73,16 @@ final class ScanViewModel { } let contact = Contact( - photo: nil, - userId: usernameAndId.1, - email: try? self.session.extract(fact: .email, from: data), - phone: try? self.session.extract(fact: .phone, from: data), - status: .stranger, + id: usernameAndId.1, marshaled: data, username: usernameAndId.0, + email: try? self.session.extract(fact: .email, from: data), + phone: try? self.session.extract(fact: .phone, from: data), nickname: nil, - createdAt: Date(), - isRecent: false + photo: nil, + authStatus: .stranger, + isRecent: false, + createdAt: Date() ) self.succeed(with: contact) diff --git a/Sources/SearchFeature/Controllers/SearchController.swift b/Sources/SearchFeature/Controllers/SearchController.swift index e192abf4d0005e266d2e3f609067dd52278cb16f..795c60009cb827d2aacca5d3e27eb89e7114b26b 100644 --- a/Sources/SearchFeature/Controllers/SearchController.swift +++ b/Sources/SearchFeature/Controllers/SearchController.swift @@ -2,13 +2,14 @@ import HUD import Theme import UIKit import Shared -import Combine -import DependencyInjection -import ScrollViewController -import DrawerFeature import Models +import Combine import Defaults +import XXModels import Countries +import DrawerFeature +import DependencyInjection +import ScrollViewController public final class SearchController: UIViewController { @KeyObject(.email, defaultValue: nil) var email: String? @@ -233,7 +234,7 @@ public final class SearchController: UIViewController { public func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { let contact = viewModel.itemsRelay.value[indexPath.row] - guard contact.status == .stranger else { + guard contact.authStatus == .stranger else { coordinator.toContact(contact, from: self) return } @@ -257,7 +258,7 @@ extension SearchController { spacingAfter: 20 ) - var subtitleFragment = "Share your information with #\(contact.username)" + var subtitleFragment = "Share your information with #\(contact.username ?? "")" if let email = contact.email { subtitleFragment.append(contentsOf: " (\(email))#") @@ -440,7 +441,7 @@ extension SearchController { ]) let drawerNicknameInput = DrawerInput( - placeholder: contact.username, + placeholder: contact.username!, validator: .init( wrongIcon: .image(Asset.sharedError.image), correctIcon: .image(Asset.sharedSuccess.image), @@ -487,7 +488,7 @@ extension SearchController { guard allowsSave else { return } drawer.dismiss(animated: true) { - self.viewModel.didSet(nickname: nickname ?? contact.username, for: contact) + self.viewModel.didSet(nickname: nickname ?? contact.username!, for: contact) } } .store(in: &drawerCancellables) diff --git a/Sources/SearchFeature/Controllers/SearchTableController.swift b/Sources/SearchFeature/Controllers/SearchTableController.swift index 625ab603defabb7d9c86b8bead13119198091e2c..a18b5856fe98c454c1a54b3ee4ca3c70d25d49dd 100644 --- a/Sources/SearchFeature/Controllers/SearchTableController.swift +++ b/Sources/SearchFeature/Controllers/SearchTableController.swift @@ -1,6 +1,7 @@ import UIKit -import Combine import Models +import Combine +import XXModels final class SearchTableController: UITableViewController { // MARK: Properties @@ -49,7 +50,7 @@ final class SearchTableController: UITableViewController { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SearchCell.self) cell.title.text = dataSource[indexPath.row].username cell.subtitle.text = dataSource[indexPath.row].username - cell.avatar.setupProfile(title: dataSource[indexPath.row].username, image: nil, size: .large) + cell.avatar.setupProfile(title: dataSource[indexPath.row].username!, image: nil, size: .large) return cell } diff --git a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift index c9a831c2c6be45b36e23d7329ad26b282f9b539e..2f66a6228fd96bb156266a80c16a9ac5ba53c5d9 100644 --- a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift +++ b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift @@ -1,5 +1,6 @@ import UIKit import Models +import XXModels import Countries import Presentation import ScrollViewController diff --git a/Sources/SearchFeature/ViewModels/SearchViewModel.swift b/Sources/SearchFeature/ViewModels/SearchViewModel.swift index 7702558f24ae08d75c701ef4203a52fe1607574f..7b52fb9762f47cfab9d17f2fd02383560be228bd 100644 --- a/Sources/SearchFeature/ViewModels/SearchViewModel.swift +++ b/Sources/SearchFeature/ViewModels/SearchViewModel.swift @@ -3,6 +3,7 @@ import UIKit import Models import Combine import Defaults +import XXModels import Countries import Foundation import Integration @@ -167,7 +168,7 @@ final class SearchViewModel { backgroundScheduler.schedule { [weak self] in guard let self = self else { return } - self.session.update(contact) + _ = try? self.session.dbManager.saveContact(contact) } } diff --git a/Sources/Shared/Extensions/FileManager.swift b/Sources/Shared/Extensions/FileManager.swift index a6512bbbc2d19c199d5b58009d280faa6a2fe984..c8639b5f82820335b584fc77d9d4887d07b7cda4 100644 --- a/Sources/Shared/Extensions/FileManager.swift +++ b/Sources/Shared/Extensions/FileManager.swift @@ -36,12 +36,13 @@ public extension FileManager { root.appendingPathComponent("\(fileName)") } - static func store(data: Data, name: String, type: String) throws { + static func store(data: Data, name: String, type: String) throws -> URL { guard let url = Self.url(for: "\(name).\(type)") else { throw NSError.create("The file path could not be retrieved") } try data.write(to: url) + return url } static func delete(name: String, type: String) { diff --git a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved index d7628f469871f2e1ed53e79524cddf88345b8ac8..eeb145af40bba398a5526019333b5de39606557e 100644 --- a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,349 +1,356 @@ { - "object": { - "pins": [ - { - "package": "abseil", - "repositoryURL": "https://github.com/firebase/abseil-cpp-SwiftPM.git", - "state": { - "branch": null, - "revision": "fffc3c2729be5747390ad02d5100291a0d9ad26a", - "version": "0.20200225.4" - } - }, - { - "package": "Alamofire", - "repositoryURL": "https://github.com/Alamofire/Alamofire.git", - "state": { - "branch": null, - "revision": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", - "version": "5.5.0" - } - }, - { - "package": "AppAuth", - "repositoryURL": "https://github.com/openid/AppAuth-iOS.git", - "state": { - "branch": null, - "revision": "01131d68346c8ae552961c768d583c715fbe1410", - "version": "1.4.0" - } - }, - { - "package": "BoringSSL-GRPC", - "repositoryURL": "https://github.com/firebase/boringssl-SwiftPM.git", - "state": { - "branch": null, - "revision": "734a8247442fde37df4364c21f6a0085b6a36728", - "version": "0.7.2" - } - }, - { - "package": "ChatLayout", - "repositoryURL": "https://github.com/ekazaev/ChatLayout", - "state": { - "branch": null, - "revision": "d0edb6f3ae716a26842467c540a6bee909b80360", - "version": "1.1.14" - } - }, - { - "package": "combine-schedulers", - "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", - "state": { - "branch": null, - "revision": "4cf088c29a20f52be0f2ca54992b492c54e0076b", - "version": "0.5.3" - } - }, - { - "package": "CwlCatchException", - "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", - "state": { - "branch": null, - "revision": "35f9e770f54ce62dd8526470f14c6e137cef3eea", - "version": "2.1.1" - } - }, - { - "package": "CwlPreconditionTesting", - "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state": { - "branch": null, - "revision": "fb7a26374e8570ff5c68142e5c83406d6abae0d8", - "version": "2.0.2" - } - }, - { - "package": "DifferenceKit", - "repositoryURL": "https://github.com/ra1028/DifferenceKit", - "state": { - "branch": null, - "revision": "62745d7780deef4a023a792a1f8f763ec7bf9705", - "version": "1.2.0" - } - }, - { - "package": "FilesProvider", - "repositoryURL": "https://github.com/amosavian/FileProvider.git", - "state": { - "branch": null, - "revision": "abf68a62541a4193c8d106367ddb3648e8ab693f", - "version": "0.26.0" - } - }, - { - "package": "Firebase", - "repositoryURL": "https://github.com/firebase/firebase-ios-sdk.git", - "state": { - "branch": null, - "revision": "08686f04881483d2bc098b2696e674c0ba135e47", - "version": "8.10.0" - } - }, - { - "package": "GoogleAPIClientForREST", - "repositoryURL": "https://github.com/google/google-api-objectivec-client-for-rest", - "state": { - "branch": null, - "revision": "22e0bb02729d60db396e8b90d8189313cd86ba53", - "version": "1.6.0" - } - }, - { - "package": "GoogleAppMeasurement", - "repositoryURL": "https://github.com/google/GoogleAppMeasurement.git", - "state": { - "branch": null, - "revision": "9b2f6aca5b4685c45f9f5481f19bee8e7982c538", - "version": "8.9.1" - } - }, - { - "package": "GoogleDataTransport", - "repositoryURL": "https://github.com/google/GoogleDataTransport.git", - "state": { - "branch": null, - "revision": "15ccdfd25ac55b9239b82809531ff26605e7556e", - "version": "9.1.2" - } - }, - { - "package": "GoogleSignIn", - "repositoryURL": "https://github.com/google/GoogleSignIn-iOS", - "state": { - "branch": null, - "revision": "60ca2bfd218ccb194a746a79b41d9d50eb7e3af0", - "version": "6.1.0" - } - }, - { - "package": "GoogleUtilities", - "repositoryURL": "https://github.com/google/GoogleUtilities.git", - "state": { - "branch": null, - "revision": "b3bb0c5551fb3f80ca939829639ab5b093edd14f", - "version": "7.7.0" - } - }, - { - "package": "GRDB", - "repositoryURL": "https://github.com/groue/GRDB.swift", - "state": { - "branch": null, - "revision": "32b2923e890df320906e64cbd0faca22a8bfda14", - "version": "5.12.0" - } - }, - { - "package": "gRPC", - "repositoryURL": "https://github.com/firebase/grpc-SwiftPM.git", - "state": { - "branch": null, - "revision": "fb405dd2c7901485f7e158b24e3a0a47e4efd8b5", - "version": "1.28.4" - } - }, - { - "package": "GTMSessionFetcher", - "repositoryURL": "https://github.com/google/gtm-session-fetcher.git", - "state": { - "branch": null, - "revision": "bc6a19702ac76ac4e488b68148710eb815f9bc56", - "version": "1.7.0" - } - }, - { - "package": "GTMAppAuth", - "repositoryURL": "https://github.com/google/GTMAppAuth.git", - "state": { - "branch": null, - "revision": "40f4103fb52109032c05599a0c39ad43edbdf80a", - "version": "1.2.2" - } - }, - { - "package": "KeychainAccess", - "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", - "state": { - "branch": null, - "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", - "version": "4.2.2" - } - }, - { - "package": "leveldb", - "repositoryURL": "https://github.com/firebase/leveldb.git", - "state": { - "branch": null, - "revision": "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", - "version": "1.22.2" - } - }, - { - "package": "nanopb", - "repositoryURL": "https://github.com/firebase/nanopb.git", - "state": { - "branch": null, - "revision": "7ee9ef9f627d85cbe1b8c4f49a3ed26eed216c77", - "version": "2.30908.0" - } - }, - { - "package": "Nimble", - "repositoryURL": "https://github.com/Quick/Nimble", - "state": { - "branch": null, - "revision": "c93f16c25af5770f0d3e6af27c9634640946b068", - "version": "9.2.1" - } - }, - { - "package": "Promises", - "repositoryURL": "https://github.com/google/promises.git", - "state": { - "branch": null, - "revision": "611337c330350c9c1823ad6d671e7f936af5ee13", - "version": "2.0.0" - } - }, - { - "package": "Quick", - "repositoryURL": "https://github.com/Quick/Quick", - "state": { - "branch": null, - "revision": "8cce6acd38f965f5baa3167b939f86500314022b", - "version": "3.1.2" - } - }, - { - "package": "Retry", - "repositoryURL": "https://github.com/icanzilb/Retry.git", - "state": { - "branch": null, - "revision": "3beacada357968bcf9e5a2da520abfb374188afe", - "version": "0.6.3" - } - }, - { - "package": "ScrollViewController", - "repositoryURL": "https://github.com/darrarski/ScrollViewController", - "state": { - "branch": null, - "revision": "9a52bb056504bb4766ddb5ac518097dd48736303", - "version": "1.2.0" - } - }, - { - "package": "SnapKit", - "repositoryURL": "https://github.com/SnapKit/SnapKit", - "state": { - "branch": null, - "revision": "d458564516e5676af9c70b4f4b2a9178294f1bc6", - "version": "5.0.1" - } - }, - { - "package": "swift-case-paths", - "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", - "state": { - "branch": null, - "revision": "241301b67d8551c26d8f09bd2c0e52cc49f18007", - "version": "0.8.0" - } - }, - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections", - "state": { - "branch": null, - "revision": "48254824bb4248676bf7ce56014ff57b142b77eb", - "version": "1.0.2" - } - }, - { - "package": "swift-composable-architecture", - "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture.git", - "state": { - "branch": null, - "revision": "313dd217dcd1d0478118ec5d15225fd473c1564a", - "version": "0.32.0" - } - }, - { - "package": "swift-custom-dump", - "repositoryURL": "https://github.com/pointfreeco/swift-custom-dump", - "state": { - "branch": null, - "revision": "51698ece74ecf31959d3fa81733f0a5363ef1b4e", - "version": "0.3.0" - } - }, - { - "package": "swift-identified-collections", - "repositoryURL": "https://github.com/pointfreeco/swift-identified-collections", - "state": { - "branch": null, - "revision": "680bf440178a78a627b1c2c64c0855f6523ad5b9", - "version": "0.3.2" - } - }, - { - "package": "SwiftProtobuf", - "repositoryURL": "https://github.com/apple/swift-protobuf", - "state": { - "branch": null, - "revision": "7e2c5f3cbbeea68e004915e3a8961e20bd11d824", - "version": "1.18.0" - } - }, - { - "package": "SwiftyBeaver", - "repositoryURL": "https://github.com/SwiftyBeaver/SwiftyBeaver.git", - "state": { - "branch": null, - "revision": "2c039501d6eeb4d4cd4aec4a8d884ad28862e044", - "version": "1.9.5" - } - }, - { - "package": "SwiftyDropbox", - "repositoryURL": "https://github.com/dropbox/SwiftyDropbox.git", - "state": { - "branch": null, - "revision": "7af87d903be1cf0af0e76e0394d992943055894e", - "version": "8.2.1" - } - }, - { - "package": "xctest-dynamic-overlay", - "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state": { - "branch": null, - "revision": "50a70a9d3583fe228ce672e8923010c8df2deddd", - "version": "0.2.1" - } - } - ] - }, - "version": 1 + "pins" : [ + { + "identity" : "abseil-cpp-swiftpm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/abseil-cpp-SwiftPM.git", + "state" : { + "revision" : "fffc3c2729be5747390ad02d5100291a0d9ad26a", + "version" : "0.20200225.4" + } + }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", + "version" : "5.5.0" + } + }, + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "01131d68346c8ae552961c768d583c715fbe1410", + "version" : "1.4.0" + } + }, + { + "identity" : "boringssl-swiftpm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/boringssl-SwiftPM.git", + "state" : { + "revision" : "734a8247442fde37df4364c21f6a0085b6a36728", + "version" : "0.7.2" + } + }, + { + "identity" : "chatlayout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ekazaev/ChatLayout", + "state" : { + "revision" : "d0edb6f3ae716a26842467c540a6bee909b80360", + "version" : "1.1.14" + } + }, + { + "identity" : "client-ios-db", + "kind" : "remoteSourceControl", + "location" : "https://git.xx.network/elixxir/client-ios-db.git", + "state" : { + "revision" : "adf3c4b906870ecbd0d1d7208f0666939fd08665", + "version" : "1.0.5" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "4cf088c29a20f52be0f2ca54992b492c54e0076b", + "version" : "0.5.3" + } + }, + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "35f9e770f54ce62dd8526470f14c6e137cef3eea", + "version" : "2.1.1" + } + }, + { + "identity" : "cwlpreconditiontesting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state" : { + "revision" : "fb7a26374e8570ff5c68142e5c83406d6abae0d8", + "version" : "2.0.2" + } + }, + { + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit", + "state" : { + "revision" : "62745d7780deef4a023a792a1f8f763ec7bf9705", + "version" : "1.2.0" + } + }, + { + "identity" : "fileprovider", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amosavian/FileProvider.git", + "state" : { + "revision" : "abf68a62541a4193c8d106367ddb3648e8ab693f", + "version" : "0.26.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "08686f04881483d2bc098b2696e674c0ba135e47", + "version" : "8.10.0" + } + }, + { + "identity" : "google-api-objectivec-client-for-rest", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/google-api-objectivec-client-for-rest", + "state" : { + "revision" : "22e0bb02729d60db396e8b90d8189313cd86ba53", + "version" : "1.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "9b2f6aca5b4685c45f9f5481f19bee8e7982c538", + "version" : "8.9.1" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "15ccdfd25ac55b9239b82809531ff26605e7556e", + "version" : "9.1.2" + } + }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS", + "state" : { + "revision" : "60ca2bfd218ccb194a746a79b41d9d50eb7e3af0", + "version" : "6.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "b3bb0c5551fb3f80ca939829639ab5b093edd14f", + "version" : "7.7.0" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "e02f2c8abacff2799ed14926edcbf6e76fb9f805", + "version" : "5.25.0" + } + }, + { + "identity" : "grpc-swiftpm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/grpc-SwiftPM.git", + "state" : { + "revision" : "fb405dd2c7901485f7e158b24e3a0a47e4efd8b5", + "version" : "1.28.4" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "bc6a19702ac76ac4e488b68148710eb815f9bc56", + "version" : "1.7.0" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "40f4103fb52109032c05599a0c39ad43edbdf80a", + "version" : "1.2.2" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", + "version" : "1.22.2" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "7ee9ef9f627d85cbe1b8c4f49a3ed26eed216c77", + "version" : "2.30908.0" + } + }, + { + "identity" : "nimble", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Nimble", + "state" : { + "revision" : "c93f16c25af5770f0d3e6af27c9634640946b068", + "version" : "9.2.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "611337c330350c9c1823ad6d671e7f936af5ee13", + "version" : "2.0.0" + } + }, + { + "identity" : "quick", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Quick", + "state" : { + "revision" : "8cce6acd38f965f5baa3167b939f86500314022b", + "version" : "3.1.2" + } + }, + { + "identity" : "retry", + "kind" : "remoteSourceControl", + "location" : "https://github.com/icanzilb/Retry.git", + "state" : { + "revision" : "3beacada357968bcf9e5a2da520abfb374188afe", + "version" : "0.6.3" + } + }, + { + "identity" : "scrollviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/darrarski/ScrollViewController", + "state" : { + "revision" : "9a52bb056504bb4766ddb5ac518097dd48736303", + "version" : "1.2.0" + } + }, + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit", + "state" : { + "revision" : "d458564516e5676af9c70b4f4b2a9178294f1bc6", + "version" : "5.0.1" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "241301b67d8551c26d8f09bd2c0e52cc49f18007", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", + "state" : { + "revision" : "313dd217dcd1d0478118ec5d15225fd473c1564a", + "version" : "0.32.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "51698ece74ecf31959d3fa81733f0a5363ef1b4e", + "version" : "0.3.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "680bf440178a78a627b1c2c64c0855f6523ad5b9", + "version" : "0.3.2" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf", + "state" : { + "revision" : "7e2c5f3cbbeea68e004915e3a8961e20bd11d824", + "version" : "1.18.0" + } + }, + { + "identity" : "swiftybeaver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftyBeaver/SwiftyBeaver.git", + "state" : { + "revision" : "2c039501d6eeb4d4cd4aec4a8d884ad28862e044", + "version" : "1.9.5" + } + }, + { + "identity" : "swiftydropbox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dropbox/SwiftyDropbox.git", + "state" : { + "revision" : "7af87d903be1cf0af0e76e0394d992943055894e", + "version" : "8.2.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd", + "version" : "0.2.1" + } + } + ], + "version" : 2 }