mv-experiment #1
|
@ -1,4 +1,4 @@
|
|||
enum ClientStoreError: Error {
|
||||
enum AppError: Error {
|
||||
case clientNotFound
|
||||
case rosterNotFound
|
||||
case imageNotFound
|
|
@ -104,13 +104,13 @@ extension Client {
|
|||
|
||||
func uploadFile(_ localURL: URL) async throws -> String {
|
||||
guard let data = try? Data(contentsOf: localURL) else {
|
||||
throw ClientStoreError.noData
|
||||
throw AppError.noData
|
||||
}
|
||||
let httpModule = connection.module(HttpFileUploadModule.self)
|
||||
|
||||
let components = try await httpModule.findHttpUploadComponents()
|
||||
guard let component = components.first(where: { $0.maxSize > data.count }) else {
|
||||
throw ClientStoreError.fileTooBig
|
||||
throw AppError.fileTooBig
|
||||
}
|
||||
|
||||
let slot = try await httpModule.requestUploadSlot(
|
||||
|
|
202
ConversationsClassic/AppData/Client/Client.swift-E
Normal file
202
ConversationsClassic/AppData/Client/Client.swift-E
Normal file
|
@ -0,0 +1,202 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Martin
|
||||
|
||||
enum ClientState: Equatable {
|
||||
enum ClientConnectionState {
|
||||
case connected
|
||||
case disconnected
|
||||
}
|
||||
|
||||
case disabled
|
||||
case enabled(ClientConnectionState)
|
||||
}
|
||||
|
||||
final class Client: ObservableObject {
|
||||
@Published private(set) var state: ClientState = .enabled(.disconnected)
|
||||
@Published private(set) var credentials: Credentials
|
||||
@Published private(set) var rosters: [Roster] = []
|
||||
|
||||
private var connection: XMPPClient
|
||||
private var connectionCancellable: AnyCancellable?
|
||||
private var rostersCancellable: AnyCancellable?
|
||||
|
||||
private var rosterManager = ClientMartinRosterManager()
|
||||
private var chatsManager = ClientMartinChatsManager()
|
||||
private var messageManager: ClientMartinMessagesManager
|
||||
private var discoManager: ClientMartinDiscoManager
|
||||
|
||||
init(credentials: Credentials) {
|
||||
self.credentials = credentials
|
||||
state = credentials.isActive ? .enabled(.disconnected) : .disabled
|
||||
connection = Self.prepareConnection(credentials, rosterManager, chatsManager)
|
||||
messageManager = ClientMartinMessagesManager(connection)
|
||||
discoManager = ClientMartinDiscoManager(connection)
|
||||
connectionCancellable = connection.$state
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
guard self.credentials.isActive else {
|
||||
self.state = .disabled
|
||||
return
|
||||
}
|
||||
rostersCancellable = ValueObservation
|
||||
.tracking { db in
|
||||
try Roster
|
||||
.filter(Column("bareJid") == self.credentials.bareJid)
|
||||
.filter(Column("locallyDeleted") == false)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.publisher(in: Database.shared.dbQueue)
|
||||
.catch { _ in Just([]) }
|
||||
.sink { rosters in
|
||||
self.rosters = rosters
|
||||
}
|
||||
switch state {
|
||||
case .connected:
|
||||
self.state = .enabled(.connected)
|
||||
|
||||
default:
|
||||
self.state = .enabled(.disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Client {
|
||||
func addRoster(_ jid: String, name: String?, groups: [String]) async throws {
|
||||
_ = try await connection.module(.roster).addItem(
|
||||
jid: JID(jid),
|
||||
name: name,
|
||||
groups: groups
|
||||
)
|
||||
}
|
||||
|
||||
func deleteRoster(_ roster: Roster) async throws {
|
||||
_ = try await connection.module(.roster).removeItem(jid: JID(roster.contactBareJid))
|
||||
}
|
||||
|
||||
func connect() async {
|
||||
guard credentials.isActive, state == .enabled(.disconnected) else {
|
||||
return
|
||||
}
|
||||
try? await connection.loginAndWait()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
_ = connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
extension Client {
|
||||
func sendMessage(_ message: Message) async throws {
|
||||
guard let to = message.to else {
|
||||
return
|
||||
}
|
||||
guard let chat = connection.module(MessageModule.self).chatManager.createChat(for: connection.context, with: BareJID(to)) else {
|
||||
return
|
||||
}
|
||||
|
||||
let msg = chat.createMessage(text: message.body ?? "??", id: message.id)
|
||||
msg.oob = message.oobUrl
|
||||
try await chat.send(message: msg)
|
||||
}
|
||||
|
||||
func uploadFile(_ localURL: URL) async throws -> String {
|
||||
guard let data = try? Data(contentsOf: localURL) else {
|
||||
throw AppError.noData
|
||||
}
|
||||
let httpModule = connection.module(HttpFileUploadModule.self)
|
||||
|
||||
let components = try await httpModule.findHttpUploadComponents()
|
||||
guard let component = components.first(where: { $0.maxSize > data.count }) else {
|
||||
throw ClientStoreError.fileTooBig
|
||||
}
|
||||
|
||||
let slot = try await httpModule.requestUploadSlot(
|
||||
componentJid: component.jid,
|
||||
filename: localURL.lastPathComponent,
|
||||
size: data.count,
|
||||
contentType: localURL.mimeType
|
||||
)
|
||||
var request = URLRequest(url: slot.putUri)
|
||||
for (key, value) in slot.putHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
request.httpMethod = "PUT"
|
||||
request.httpBody = data
|
||||
request.addValue(String(data.count), forHTTPHeaderField: "Content-Length")
|
||||
request.addValue(localURL.mimeType, forHTTPHeaderField: "Content-Type")
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
switch response {
|
||||
case let httpResponse as HTTPURLResponse where httpResponse.statusCode == 201:
|
||||
return slot.getUri.absoluteString
|
||||
|
||||
default:
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func requestArchivedMessages(for roster: Roster) async {
|
||||
print(roster)
|
||||
|
||||
// if !discoManager.features.map({ $0.xep }).contains("XEP-0313") {
|
||||
// return
|
||||
// }
|
||||
// let module = connection.module(MessageArchiveManagementModule.self)
|
||||
// let endDate = Date()
|
||||
// let startDate = Calendar.current.date(byAdding: .day, value: -Const.mamRequestDaysLength, to: endDate) ?? Date()
|
||||
// let response = try? await module.queryItems(componentJid: JID(credentials.bareJid), with: JID(roster.bareJid), start: startDate, end: endDate, queryId: UUID().uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
extension Client {
|
||||
static func tryLogin(with credentials: Credentials) async throws -> Client {
|
||||
let client = Client(credentials: credentials)
|
||||
try await client.connection.loginAndWait()
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
private extension Client {
|
||||
static func prepareConnection(_ credentials: Credentials, _ roster: RosterManager, _ chat: ChatManager) -> XMPPClient {
|
||||
let client = XMPPClient()
|
||||
|
||||
// register modules
|
||||
// core modules RFC 6120
|
||||
client.modulesManager.register(StreamFeaturesModule())
|
||||
client.modulesManager.register(SaslModule())
|
||||
client.modulesManager.register(AuthModule())
|
||||
client.modulesManager.register(SessionEstablishmentModule())
|
||||
client.modulesManager.register(ResourceBinderModule())
|
||||
client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName)))
|
||||
|
||||
// messaging modules RFC 6121
|
||||
client.modulesManager.register(RosterModule(rosterManager: roster))
|
||||
client.modulesManager.register(PresenceModule())
|
||||
|
||||
client.modulesManager.register(PubSubModule())
|
||||
client.modulesManager.register(MessageModule(chatManager: chat))
|
||||
client.modulesManager.register(MessageArchiveManagementModule())
|
||||
|
||||
client.modulesManager.register(MessageCarbonsModule())
|
||||
|
||||
// file transfer modules
|
||||
client.modulesManager.register(HttpFileUploadModule())
|
||||
|
||||
// extensions
|
||||
client.modulesManager.register(SoftwareVersionModule())
|
||||
client.modulesManager.register(PingModule())
|
||||
client.connectionConfiguration.userJid = .init(credentials.bareJid)
|
||||
client.connectionConfiguration.credentials = .password(password: credentials.pass)
|
||||
|
||||
// group chats
|
||||
// client.modulesManager.register(MucModule(roomManager: manager))
|
||||
|
||||
// channels
|
||||
// client.modulesManager.register(MixModule(channelManager: manager))
|
||||
|
||||
// add client to clients
|
||||
return client
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ extension Chat {
|
|||
.filter(Column("bareJid") == account && Column("contactBareJid") == participant)
|
||||
.fetchOne(db)
|
||||
else {
|
||||
throw ClientStoreError.rosterNotFound
|
||||
throw AppError.rosterNotFound
|
||||
}
|
||||
return roster
|
||||
}
|
||||
|
|
|
@ -247,10 +247,10 @@ extension AttachmentsStore {
|
|||
try await message.setStatus(.pending)
|
||||
var message = message
|
||||
guard case .attachment(let attachment) = message.contentType else {
|
||||
throw ClientStoreError.invalidContentType
|
||||
throw AppError.invalidContentType
|
||||
}
|
||||
guard let localName = attachment.localPath else {
|
||||
throw ClientStoreError.invalidLocalName
|
||||
throw AppError.invalidLocalName
|
||||
}
|
||||
let remotePath = try await client.uploadFile(localName)
|
||||
message.contentType = .attachment(
|
||||
|
|
352
ConversationsClassic/AppData/Store/AttachmentsStore.swift-E
Normal file
352
ConversationsClassic/AppData/Store/AttachmentsStore.swift-E
Normal file
|
@ -0,0 +1,352 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Photos
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AttachmentsStore: ObservableObject {
|
||||
@Published private(set) var cameraAccessGranted = false
|
||||
@Published private(set) var galleryAccessGranted = false
|
||||
@Published private(set) var galleryItems: [GalleryItem] = []
|
||||
|
||||
private let client: Client
|
||||
private let roster: Roster
|
||||
|
||||
private var messagesCancellable: AnyCancellable?
|
||||
private var processing: Set<String> = []
|
||||
|
||||
init(roster: Roster, client: Client) {
|
||||
self.client = client
|
||||
self.roster = roster
|
||||
subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Camera and Gallery access
|
||||
extension AttachmentsStore {
|
||||
func checkCameraAuthorization() async {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
var isAuthorized = status == .authorized
|
||||
if status == .notDetermined {
|
||||
isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
|
||||
}
|
||||
cameraAccessGranted = isAuthorized
|
||||
}
|
||||
|
||||
func checkGalleryAuthorization() async {
|
||||
let status = PHPhotoLibrary.authorizationStatus()
|
||||
var isAuthorized = status == .authorized
|
||||
if status == .notDetermined {
|
||||
let req = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||||
isAuthorized = (req == .authorized) || (req == .limited)
|
||||
}
|
||||
galleryAccessGranted = isAuthorized
|
||||
if isAuthorized {
|
||||
await fetchGalleryItems()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchGalleryItems() async {
|
||||
guard galleryAccessGranted else { return }
|
||||
galleryItems = await GalleryItem.fetchAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save outgoing attachments for future uploadings
|
||||
extension AttachmentsStore {
|
||||
func sendMedia(_ items: [GalleryItem]) {
|
||||
Task {
|
||||
for item in items {
|
||||
Task {
|
||||
var message = Message.blank
|
||||
message.from = roster.bareJid
|
||||
message.to = roster.contactBareJid
|
||||
|
||||
switch item.type {
|
||||
case .photo:
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return }
|
||||
guard let photo = try? await PHImageManager.default().getPhoto(for: asset) else { return }
|
||||
guard let data = photo.jpegData(compressionQuality: 1.0) else { return }
|
||||
let localName = "\(message.id)_\(UUID().uuidString).jpg"
|
||||
let localUrl = Const.fileFolder.appendingPathComponent(localName)
|
||||
try? data.write(to: localUrl)
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: .image,
|
||||
localName: localName,
|
||||
thumbnailName: nil,
|
||||
remotePath: nil
|
||||
)
|
||||
)
|
||||
try? await message.save()
|
||||
|
||||
case .video:
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return }
|
||||
guard let video = try? await PHImageManager.default().getVideo(for: asset) else { return }
|
||||
// swiftlint:disable:next force_cast
|
||||
let assetURL = video as! AVURLAsset
|
||||
let url = assetURL.url
|
||||
let localName = "\(message.id)_\(UUID().uuidString).mov"
|
||||
let localUrl = Const.fileFolder.appendingPathComponent(localName)
|
||||
try? FileManager.default.copyItem(at: url, to: localUrl)
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: .video,
|
||||
localName: localName,
|
||||
thumbnailName: nil,
|
||||
remotePath: nil
|
||||
)
|
||||
)
|
||||
try? await message.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendCaptured(_ data: Data, _ type: GalleryMediaType) {
|
||||
Task {
|
||||
var message = Message.blank
|
||||
message.from = roster.bareJid
|
||||
message.to = roster.contactBareJid
|
||||
|
||||
let localName: String
|
||||
let msgType: AttachmentType
|
||||
do {
|
||||
(localName, msgType) = try await Task {
|
||||
// local name
|
||||
let fileId = UUID().uuidString
|
||||
let localName: String
|
||||
let msgType: AttachmentType
|
||||
switch type {
|
||||
case .photo:
|
||||
localName = "\(message.id)_\(fileId).jpg"
|
||||
msgType = .image
|
||||
|
||||
case .video:
|
||||
localName = "\(message.id)_\(fileId).mov"
|
||||
msgType = .video
|
||||
}
|
||||
|
||||
// save
|
||||
let localUrl = Const.fileFolder.appendingPathComponent(localName)
|
||||
try data.write(to: localUrl)
|
||||
return (localName, msgType)
|
||||
}.value
|
||||
} catch {
|
||||
logIt(.error, "Can't save file for uploading: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// save message
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: msgType,
|
||||
localName: localName,
|
||||
thumbnailName: nil,
|
||||
remotePath: nil
|
||||
)
|
||||
)
|
||||
do {
|
||||
try await message.save()
|
||||
} catch {
|
||||
logIt(.error, "Can't save message: \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendDocuments(_ data: [Data], _ extensions: [String]) {
|
||||
Task {
|
||||
for (index, data) in data.enumerated() {
|
||||
Task {
|
||||
let newMessageId = UUID().uuidString
|
||||
let fileId = UUID().uuidString
|
||||
let localName = "\(newMessageId)_\(fileId).\(extensions[index])"
|
||||
let localUrl = Const.fileFolder.appendingPathComponent(localName)
|
||||
do {
|
||||
try data.write(to: localUrl)
|
||||
} catch {
|
||||
print("FileProcessing: Error writing document: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
var message = Message.blank
|
||||
message.from = roster.bareJid
|
||||
message.to = roster.contactBareJid
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: localName.attachmentType,
|
||||
localName: localName,
|
||||
thumbnailName: nil,
|
||||
remotePath: nil
|
||||
)
|
||||
)
|
||||
do {
|
||||
try await message.save()
|
||||
} catch {
|
||||
print("FileProcessing: Error saving document: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Processing attachments
|
||||
private extension AttachmentsStore {
|
||||
func subscribe() {
|
||||
messagesCancellable = ValueObservation.tracking(Message
|
||||
.filter(
|
||||
(Column("to") == roster.bareJid && Column("from") == roster.contactBareJid) ||
|
||||
(Column("from") == roster.bareJid && Column("to") == roster.contactBareJid)
|
||||
)
|
||||
.order(Column("date").desc)
|
||||
.fetchAll
|
||||
)
|
||||
.publisher(in: Database.shared.dbQueue, scheduling: .immediate)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
} receiveValue: { [weak self] messages in
|
||||
let forProcessing = messages
|
||||
// .filter { $0.status == .pending }
|
||||
.filter { self?.processing.contains($0.id) == false }
|
||||
.filter { $0.contentType.isAttachment }
|
||||
for message in forProcessing {
|
||||
if case .attachment(let attachment) = message.contentType {
|
||||
if attachment.localPath != nil, attachment.remotePath == nil {
|
||||
// Uploading
|
||||
self?.processing.insert(message.id)
|
||||
Task(priority: .background) {
|
||||
await self?.uploadAttachment(message)
|
||||
}
|
||||
} else if attachment.localPath == nil, attachment.remotePath != nil {
|
||||
// Downloading
|
||||
self?.processing.insert(message.id)
|
||||
Task(priority: .background) {
|
||||
await self?.downloadAttachment(message)
|
||||
}
|
||||
} else if attachment.localPath != nil, attachment.remotePath != nil, attachment.thumbnailName == nil, attachment.type == .image {
|
||||
// Generate thumbnail
|
||||
self?.processing.insert(message.id)
|
||||
Task(priority: .background) {
|
||||
await self?.generateThumbnail(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Uploadings/Downloadings
|
||||
extension AttachmentsStore {
|
||||
private func uploadAttachment(_ message: Message) async {
|
||||
do {
|
||||
try await message.setStatus(.pending)
|
||||
var message = message
|
||||
guard case .attachment(let attachment) = message.contentType else {
|
||||
throw AppError.invalidContentType
|
||||
}
|
||||
guard let localName = attachment.localPath else {
|
||||
throw ClientStoreError.invalidLocalName
|
||||
}
|
||||
let remotePath = try await client.uploadFile(localName)
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: attachment.type,
|
||||
localName: attachment.localName,
|
||||
thumbnailName: nil,
|
||||
remotePath: remotePath
|
||||
)
|
||||
)
|
||||
message.body = remotePath
|
||||
message.oobUrl = remotePath
|
||||
try await message.save()
|
||||
try await client.sendMessage(message)
|
||||
processing.remove(message.id)
|
||||
try await message.setStatus(.sent)
|
||||
} catch {
|
||||
processing.remove(message.id)
|
||||
try? await message.setStatus(.error)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAttachment(_ message: Message) async {
|
||||
guard case .attachment(let attachment) = message.contentType else {
|
||||
return
|
||||
}
|
||||
guard let remotePath = attachment.remotePath, let remoteUrl = URL(string: remotePath) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let localName = "\(message.id)_\(UUID().uuidString).\(remoteUrl.lastPathComponent)"
|
||||
let localUrl = Const.fileFolder.appendingPathComponent(localName)
|
||||
|
||||
// Download the file
|
||||
let (tempUrl, _) = try await URLSession.shared.download(from: remoteUrl)
|
||||
try FileManager.default.moveItem(at: tempUrl, to: localUrl)
|
||||
|
||||
var message = message
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: attachment.type,
|
||||
localName: localName,
|
||||
thumbnailName: attachment.thumbnailName,
|
||||
remotePath: remotePath
|
||||
)
|
||||
)
|
||||
processing.remove(message.id)
|
||||
try await message.save()
|
||||
} catch {
|
||||
logIt(.error, "Can't download attachment: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func generateThumbnail(_ message: Message) async {
|
||||
guard case .attachment(let attachment) = message.contentType else {
|
||||
return
|
||||
}
|
||||
guard attachment.type == .image else {
|
||||
return
|
||||
}
|
||||
guard let localName = attachment.localName, let localPath = attachment.localPath else {
|
||||
return
|
||||
}
|
||||
let thumbnailFileName = "thumb_\(localName)"
|
||||
let thumbnailUrl = Const.fileFolder.appendingPathComponent(thumbnailFileName)
|
||||
|
||||
//
|
||||
if !FileManager.default.fileExists(atPath: thumbnailUrl.path) {
|
||||
guard let image = UIImage(contentsOfFile: localPath.path) else {
|
||||
return
|
||||
}
|
||||
let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize)
|
||||
guard let thumbnail = try? await image.scaleAndCropImage(targetSize) else {
|
||||
return
|
||||
}
|
||||
guard let data = thumbnail.jpegData(compressionQuality: 0.5) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try data.write(to: thumbnailUrl)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
var message = message
|
||||
message.contentType = .attachment(
|
||||
Attachment(
|
||||
type: attachment.type,
|
||||
localName: attachment.localName,
|
||||
thumbnailName: thumbnailFileName,
|
||||
remotePath: attachment.remotePath
|
||||
)
|
||||
)
|
||||
processing.remove(message.id)
|
||||
try? await message.save()
|
||||
}
|
||||
}
|
|
@ -108,14 +108,14 @@ extension ClientsStore {
|
|||
|
||||
// add new roster
|
||||
guard let client = client(for: credentials) else {
|
||||
throw ClientStoreError.clientNotFound
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
try await client.addRoster(contactJID, name: name, groups: groups)
|
||||
}
|
||||
|
||||
func deleteRoster(_ roster: Roster) async throws {
|
||||
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
||||
throw ClientStoreError.clientNotFound
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
try await client.deleteRoster(roster)
|
||||
}
|
||||
|
@ -147,7 +147,7 @@ extension ClientsStore {
|
|||
}
|
||||
|
||||
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
||||
throw ClientStoreError.clientNotFound
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
|
||||
let conversationStore = ConversationStore(roster: roster, client: client)
|
||||
|
@ -161,7 +161,7 @@ extension ClientsStore {
|
|||
}
|
||||
|
||||
guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else {
|
||||
throw ClientStoreError.clientNotFound
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
|
||||
let roster = try await chat.fetchRoster()
|
||||
|
|
172
ConversationsClassic/AppData/Store/ClientsStore.swift-E
Normal file
172
ConversationsClassic/AppData/Store/ClientsStore.swift-E
Normal file
|
@ -0,0 +1,172 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
@MainActor
|
||||
final class ClientsStore: ObservableObject {
|
||||
static let shared = ClientsStore()
|
||||
|
||||
@Published private(set) var ready = false
|
||||
@Published private(set) var clients: [Client] = []
|
||||
@Published private(set) var actualRosters: [Roster] = []
|
||||
@Published private(set) var actualChats: [Chat] = []
|
||||
|
||||
private var credentialsCancellable: AnyCancellable?
|
||||
private var rostersCancellable: AnyCancellable?
|
||||
private var chatsCancellable: AnyCancellable?
|
||||
|
||||
init() {
|
||||
credentialsCancellable = ValueObservation
|
||||
.tracking { db in
|
||||
try Credentials.fetchAll(db)
|
||||
}
|
||||
.publisher(in: Database.shared.dbQueue)
|
||||
.catch { _ in Just([]) }
|
||||
.sink { [weak self] creds in
|
||||
self?.processCredentials(creds)
|
||||
}
|
||||
}
|
||||
|
||||
private func processCredentials(_ credentials: [Credentials]) {
|
||||
let existsJids = Set(clients.map { $0.credentials.bareJid })
|
||||
let credentialsJids = Set(credentials.map { $0.bareJid })
|
||||
|
||||
let forAdd = credentials.filter { !existsJids.contains($0.bareJid) }
|
||||
let newClients = forAdd.map { Client(credentials: $0) }
|
||||
|
||||
let forRemove = clients.filter { !credentialsJids.contains($0.credentials.bareJid) }
|
||||
forRemove.forEach { $0.disconnect() }
|
||||
|
||||
var updatedClients = clients.filter { credentialsJids.contains($0.credentials.bareJid) }
|
||||
updatedClients.append(contentsOf: newClients)
|
||||
clients = updatedClients
|
||||
|
||||
if !ready {
|
||||
ready = true
|
||||
}
|
||||
|
||||
resubscribeRosters()
|
||||
resubscribeChats()
|
||||
reconnectAll()
|
||||
}
|
||||
|
||||
private func client(for credentials: Credentials) -> Client? {
|
||||
clients.first { $0.credentials == credentials }
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientsStore {
|
||||
func tryLogin(_ jidStr: String, _ pass: String) async throws {
|
||||
// login with fake timeout
|
||||
async let sleep: Void? = try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||
async let request = try await Client.tryLogin(with: .init(bareJid: jidStr, pass: pass, isActive: true))
|
||||
let client = try await(request, sleep).0
|
||||
|
||||
clients.append(client)
|
||||
try? await client.credentials.save()
|
||||
}
|
||||
|
||||
private func reconnectAll() {
|
||||
Task {
|
||||
await withTaskGroup(of: Void.self) { taskGroup in
|
||||
for client in clients {
|
||||
taskGroup.addTask {
|
||||
await client.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientsStore {
|
||||
private func resubscribeRosters() {
|
||||
let clientsJids = clients
|
||||
.filter { $0.state != .disabled }
|
||||
.map { $0.credentials.bareJid }
|
||||
|
||||
rostersCancellable = ValueObservation.tracking { db in
|
||||
try Roster
|
||||
.filter(clientsJids.contains(Column("bareJid")))
|
||||
.filter(Column("locallyDeleted") == false)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.publisher(in: Database.shared.dbQueue)
|
||||
.catch { _ in Just([]) }
|
||||
.sink { [weak self] rosters in
|
||||
self?.actualRosters = rosters
|
||||
}
|
||||
}
|
||||
|
||||
func addRoster(_ credentials: Credentials, contactJID: String, name: String?, groups: [String]) async throws {
|
||||
// check that roster exist in db as locally deleted and undelete it
|
||||
let deletedLocally = try await Roster.fetchDeletedLocally()
|
||||
if var roster = deletedLocally.first(where: { $0.contactBareJid == contactJID }) {
|
||||
try await roster.setLocallyDeleted(false)
|
||||
return
|
||||
}
|
||||
|
||||
// add new roster
|
||||
guard let client = client(for: credentials) else {
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
try await client.addRoster(contactJID, name: name, groups: groups)
|
||||
}
|
||||
|
||||
func deleteRoster(_ roster: Roster) async throws {
|
||||
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
try await client.deleteRoster(roster)
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientsStore {
|
||||
private func resubscribeChats() {
|
||||
let clientsJids = clients
|
||||
.filter { $0.state != .disabled }
|
||||
.map { $0.credentials.bareJid }
|
||||
|
||||
chatsCancellable = ValueObservation.tracking { db in
|
||||
try Chat
|
||||
.filter(clientsJids.contains(Column("account")))
|
||||
.fetchAll(db)
|
||||
}
|
||||
.publisher(in: Database.shared.dbQueue)
|
||||
.catch { _ in Just([]) }
|
||||
.sink { [weak self] chats in
|
||||
self?.actualChats = chats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientsStore {
|
||||
func conversationStores(for roster: Roster) async throws -> (ConversationStore, AttachmentsStore) {
|
||||
while !ready {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
||||
throw AppError.clientNotFound
|
||||
}
|
||||
|
||||
let conversationStore = ConversationStore(roster: roster, client: client)
|
||||
let attachmentsStore = AttachmentsStore(roster: roster, client: client)
|
||||
return (conversationStore, attachmentsStore)
|
||||
}
|
||||
|
||||
func conversationStores(for chat: Chat) async throws -> (ConversationStore, AttachmentsStore) {
|
||||
while !ready {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else {
|
||||
throw ClientStoreError.clientNotFound
|
||||
}
|
||||
|
||||
let roster = try await chat.fetchRoster()
|
||||
let conversationStore = ConversationStore(roster: roster, client: client)
|
||||
let attachmentsStore = AttachmentsStore(roster: roster, client: client)
|
||||
return (conversationStore, attachmentsStore)
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ extension PHImageManager {
|
|||
if let image {
|
||||
continuation.resume(returning: image)
|
||||
} else {
|
||||
continuation.resume(throwing: ClientStoreError.imageNotFound)
|
||||
continuation.resume(throwing: AppError.imageNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ extension PHImageManager {
|
|||
if let avAsset {
|
||||
continuation.resume(returning: avAsset)
|
||||
} else {
|
||||
continuation.resume(throwing: ClientStoreError.videoNotFound)
|
||||
continuation.resume(throwing: AppError.videoNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue