Compare commits
	
		
			5 Commits
		
	
	
		
			7dc78edb02
			...
			cc32d7acad
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cc32d7acad | |||
| da5f198d8c | |||
| 49ac88c23c | |||
| 7c8940da5b | |||
| 6fd82e25c1 | 
@ -28,12 +28,12 @@ struct TopBarView: View {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private var statusMessage: String? {
 | 
					    private var statusMessage: String? {
 | 
				
			||||||
        if viewModel.socketState != .connected {
 | 
					 | 
				
			||||||
            return NSLocalizedString("Подключение", comment: "")
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        if viewModel.chatLoadingState == .loading {
 | 
					        if viewModel.chatLoadingState == .loading {
 | 
				
			||||||
            return NSLocalizedString("Загрузка чатов", comment: "")
 | 
					            return NSLocalizedString("Загрузка чатов", comment: "")
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        if viewModel.socketState == .connecting {
 | 
				
			||||||
 | 
					            return NSLocalizedString("Подключение", comment: "")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        return nil
 | 
					        return nil
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										233
									
								
								yobble/CoreData/ChatCacheWriter.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								yobble/CoreData/ChatCacheWriter.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,233 @@
 | 
				
			|||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					import CoreData
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class ChatCacheWriter {
 | 
				
			||||||
 | 
					    static let shared = ChatCacheWriter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private let persistenceController: PersistenceController
 | 
				
			||||||
 | 
					    private let userDefaults: UserDefaults
 | 
				
			||||||
 | 
					    private let keychainService: KeychainService
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init(
 | 
				
			||||||
 | 
					        persistenceController: PersistenceController = .shared,
 | 
				
			||||||
 | 
					        userDefaults: UserDefaults = .standard,
 | 
				
			||||||
 | 
					        keychainService: KeychainService = .shared
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        self.persistenceController = persistenceController
 | 
				
			||||||
 | 
					        self.userDefaults = userDefaults
 | 
				
			||||||
 | 
					        self.keychainService = keychainService
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func storePrivateChatList(_ data: PrivateChatListData) {
 | 
				
			||||||
 | 
					        guard !AppConfig.DISABLE_DB else { return }
 | 
				
			||||||
 | 
					        guard let accountUserId = resolveCurrentAccountUserId() else { return }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let context = persistenceController.newBackgroundContext()
 | 
				
			||||||
 | 
					        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        context.perform { [weak self] in
 | 
				
			||||||
 | 
					            guard let self else { return }
 | 
				
			||||||
 | 
					            do {
 | 
				
			||||||
 | 
					                try self.sync(chatList: data, accountUserId: accountUserId, context: context)
 | 
				
			||||||
 | 
					                if context.hasChanges {
 | 
				
			||||||
 | 
					                    try context.save()
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            } catch {
 | 
				
			||||||
 | 
					                if AppConfig.DEBUG {
 | 
				
			||||||
 | 
					                    print("[ChatCacheWriter] Failed to store private chat list: \(error)")
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func resolveCurrentAccountUserId() -> String? {
 | 
				
			||||||
 | 
					        guard let login = userDefaults.string(forKey: "currentUser") else {
 | 
				
			||||||
 | 
					            return nil
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return keychainService.get(forKey: "userId", service: login)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func sync(chatList: PrivateChatListData, accountUserId: String, context: NSManagedObjectContext) throws {
 | 
				
			||||||
 | 
					        var profileCache: [String: CDProfile] = [:]
 | 
				
			||||||
 | 
					        var chatCache: [String: CDPrivateChat] = [:]
 | 
				
			||||||
 | 
					        var messageCache: [String: CDMessage] = [:]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for item in chatList.items {
 | 
				
			||||||
 | 
					            let chat = try upsertChat(item, accountUserId: accountUserId, context: context, cache: &chatCache)
 | 
				
			||||||
 | 
					            let previousLastMessage = chat.lastMessage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if let profileData = item.chatData {
 | 
				
			||||||
 | 
					                let profile = try upsertProfile(profileData, accountUserId: accountUserId, context: context, cache: &profileCache)
 | 
				
			||||||
 | 
					                chat.chatData = profile
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                chat.chatData = nil
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if let lastMessageData = item.lastMessage {
 | 
				
			||||||
 | 
					                let message = try upsertMessage(
 | 
				
			||||||
 | 
					                    lastMessageData,
 | 
				
			||||||
 | 
					                    accountUserId: accountUserId,
 | 
				
			||||||
 | 
					                    chat: chat,
 | 
				
			||||||
 | 
					                    context: context,
 | 
				
			||||||
 | 
					                    profileCache: &profileCache,
 | 
				
			||||||
 | 
					                    messageCache: &messageCache
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                if let previous = previousLastMessage, previous.objectID != message.objectID {
 | 
				
			||||||
 | 
					                    previous.chatAsLastMessage = nil
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                chat.lastMessage = message
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                previousLastMessage?.chatAsLastMessage = nil
 | 
				
			||||||
 | 
					                chat.lastMessage = nil
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func upsertChat(
 | 
				
			||||||
 | 
					        _ chatItem: PrivateChatListItem,
 | 
				
			||||||
 | 
					        accountUserId: String,
 | 
				
			||||||
 | 
					        context: NSManagedObjectContext,
 | 
				
			||||||
 | 
					        cache: inout [String: CDPrivateChat]
 | 
				
			||||||
 | 
					    ) throws -> CDPrivateChat {
 | 
				
			||||||
 | 
					        if let cached = cache[chatItem.chatId] {
 | 
				
			||||||
 | 
					            apply(chatItem: chatItem, to: cached, accountUserId: accountUserId)
 | 
				
			||||||
 | 
					            return cached
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let chat = try fetchChat(chatId: chatItem.chatId, accountUserId: accountUserId, context: context) ?? CDPrivateChat(context: context)
 | 
				
			||||||
 | 
					        apply(chatItem: chatItem, to: chat, accountUserId: accountUserId)
 | 
				
			||||||
 | 
					        cache[chatItem.chatId] = chat
 | 
				
			||||||
 | 
					        return chat
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func apply(chatItem: PrivateChatListItem, to chat: CDPrivateChat, accountUserId: String) {
 | 
				
			||||||
 | 
					        chat.accountUserId = accountUserId
 | 
				
			||||||
 | 
					        chat.chatId = chatItem.chatId
 | 
				
			||||||
 | 
					        chat.chatType = chatItem.chatType.rawValue
 | 
				
			||||||
 | 
					        chat.createdAt = chatItem.createdAt
 | 
				
			||||||
 | 
					        chat.unreadCount = Int32(chatItem.unreadCount)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func upsertProfile(
 | 
				
			||||||
 | 
					        _ profileData: ChatProfile,
 | 
				
			||||||
 | 
					        accountUserId: String,
 | 
				
			||||||
 | 
					        context: NSManagedObjectContext,
 | 
				
			||||||
 | 
					        cache: inout [String: CDProfile]
 | 
				
			||||||
 | 
					    ) throws -> CDProfile {
 | 
				
			||||||
 | 
					        if let cached = cache[profileData.userId] {
 | 
				
			||||||
 | 
					            apply(profile: profileData, to: cached, accountUserId: accountUserId)
 | 
				
			||||||
 | 
					            return cached
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let profile = try fetchProfile(userId: profileData.userId, accountUserId: accountUserId, context: context) ?? CDProfile(context: context)
 | 
				
			||||||
 | 
					        apply(profile: profileData, to: profile, accountUserId: accountUserId)
 | 
				
			||||||
 | 
					        cache[profileData.userId] = profile
 | 
				
			||||||
 | 
					        return profile
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func apply(profile profileData: ChatProfile, to profile: CDProfile, accountUserId: String) {
 | 
				
			||||||
 | 
					        profile.accountUserId = accountUserId
 | 
				
			||||||
 | 
					        profile.userId = profileData.userId
 | 
				
			||||||
 | 
					        profile.login = profileData.login
 | 
				
			||||||
 | 
					        profile.fullName = profileData.fullName
 | 
				
			||||||
 | 
					        profile.customName = profileData.customName
 | 
				
			||||||
 | 
					        profile.bio = profileData.bio
 | 
				
			||||||
 | 
					        if let lastSeen = profileData.lastSeen {
 | 
				
			||||||
 | 
					            profile.setValue(NSNumber(value: lastSeen), forKey: "lastSeen")
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            profile.setValue(nil, forKey: "lastSeen")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        profile.createdAt = profileData.createdAt
 | 
				
			||||||
 | 
					        profile.isOfficial = profileData.isOfficial
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func upsertMessage(
 | 
				
			||||||
 | 
					        _ messageData: MessageItem,
 | 
				
			||||||
 | 
					        accountUserId: String,
 | 
				
			||||||
 | 
					        chat: CDPrivateChat,
 | 
				
			||||||
 | 
					        context: NSManagedObjectContext,
 | 
				
			||||||
 | 
					        profileCache: inout [String: CDProfile],
 | 
				
			||||||
 | 
					        messageCache: inout [String: CDMessage]
 | 
				
			||||||
 | 
					    ) throws -> CDMessage {
 | 
				
			||||||
 | 
					        if let cached = messageCache[messageData.messageId] {
 | 
				
			||||||
 | 
					            apply(message: messageData, to: cached, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache)
 | 
				
			||||||
 | 
					            return cached
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let message = try fetchMessage(messageId: messageData.messageId, accountUserId: accountUserId, context: context) ?? CDMessage(context: context)
 | 
				
			||||||
 | 
					        apply(message: messageData, to: message, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache)
 | 
				
			||||||
 | 
					        messageCache[messageData.messageId] = message
 | 
				
			||||||
 | 
					        return message
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func apply(
 | 
				
			||||||
 | 
					        message messageData: MessageItem,
 | 
				
			||||||
 | 
					        to message: CDMessage,
 | 
				
			||||||
 | 
					        accountUserId: String,
 | 
				
			||||||
 | 
					        chat: CDPrivateChat,
 | 
				
			||||||
 | 
					        context: NSManagedObjectContext,
 | 
				
			||||||
 | 
					        profileCache: inout [String: CDProfile]
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        message.accountUserId = accountUserId
 | 
				
			||||||
 | 
					        message.messageId = messageData.messageId
 | 
				
			||||||
 | 
					        message.chatId = messageData.chatId
 | 
				
			||||||
 | 
					        message.messageType = messageData.messageType
 | 
				
			||||||
 | 
					        message.content = messageData.content
 | 
				
			||||||
 | 
					        message.mediaLink = messageData.mediaLink
 | 
				
			||||||
 | 
					        message.createdAt = messageData.createdAt
 | 
				
			||||||
 | 
					        message.updatedAt = messageData.updatedAt
 | 
				
			||||||
 | 
					        message.senderId = messageData.senderId
 | 
				
			||||||
 | 
					        message.chat = chat
 | 
				
			||||||
 | 
					        message.chatAsLastMessage = chat
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let isViewed = messageData.isViewed {
 | 
				
			||||||
 | 
					            message.setValue(NSNumber(value: isViewed), forKey: "isViewed")
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            message.setValue(nil, forKey: "isViewed")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let forward = messageData.forwardMetadata {
 | 
				
			||||||
 | 
					            message.forwardType = forward.forwardType
 | 
				
			||||||
 | 
					            message.forwardSenderId = forward.forwardSenderId
 | 
				
			||||||
 | 
					            message.forwardMessageId = forward.forwardMessageId
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            message.forwardType = nil
 | 
				
			||||||
 | 
					            message.forwardSenderId = nil
 | 
				
			||||||
 | 
					            message.forwardMessageId = nil
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let senderProfileData = messageData.senderData {
 | 
				
			||||||
 | 
					            if let profile = try? upsertProfile(
 | 
				
			||||||
 | 
					                senderProfileData,
 | 
				
			||||||
 | 
					                accountUserId: accountUserId,
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                cache: &profileCache
 | 
				
			||||||
 | 
					            ) {
 | 
				
			||||||
 | 
					                message.sender = profile
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            message.sender = nil
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func fetchChat(chatId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDPrivateChat? {
 | 
				
			||||||
 | 
					        let request = NSFetchRequest<CDPrivateChat>(entityName: "CDPrivateChat")
 | 
				
			||||||
 | 
					        request.fetchLimit = 1
 | 
				
			||||||
 | 
					        request.predicate = NSPredicate(format: "accountUserId == %@ AND chatId == %@", accountUserId, chatId)
 | 
				
			||||||
 | 
					        return try context.fetch(request).first
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func fetchProfile(userId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDProfile? {
 | 
				
			||||||
 | 
					        let request = NSFetchRequest<CDProfile>(entityName: "CDProfile")
 | 
				
			||||||
 | 
					        request.fetchLimit = 1
 | 
				
			||||||
 | 
					        request.predicate = NSPredicate(format: "accountUserId == %@ AND userId == %@", accountUserId, userId)
 | 
				
			||||||
 | 
					        return try context.fetch(request).first
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func fetchMessage(messageId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDMessage? {
 | 
				
			||||||
 | 
					        let request = NSFetchRequest<CDMessage>(entityName: "CDMessage")
 | 
				
			||||||
 | 
					        request.fetchLimit = 1
 | 
				
			||||||
 | 
					        request.predicate = NSPredicate(format: "accountUserId == %@ AND messageId == %@", accountUserId, messageId)
 | 
				
			||||||
 | 
					        return try context.fetch(request).first
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										101
									
								
								yobble/CoreData/PersistenceController.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								yobble/CoreData/PersistenceController.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,101 @@
 | 
				
			|||||||
 | 
					import CoreData
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum PersistenceControllerError: Error {
 | 
				
			||||||
 | 
					    case encryptionKeyUnavailable
 | 
				
			||||||
 | 
					    case persistentStoreMissing
 | 
				
			||||||
 | 
					    case rekeyingUnavailable
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class PersistenceController {
 | 
				
			||||||
 | 
					    static let shared: PersistenceController = {
 | 
				
			||||||
 | 
					        do {
 | 
				
			||||||
 | 
					            return try PersistenceController()
 | 
				
			||||||
 | 
					        } catch {
 | 
				
			||||||
 | 
					            fatalError("Failed to initialize PersistenceController: \(error)")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let container: NSPersistentContainer
 | 
				
			||||||
 | 
					    private let keyManager: DatabaseEncryptionKeyManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var viewContext: NSManagedObjectContext {
 | 
				
			||||||
 | 
					        container.viewContext
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init(
 | 
				
			||||||
 | 
					        inMemory: Bool = false,
 | 
				
			||||||
 | 
					        keyManager: DatabaseEncryptionKeyManager = .shared,
 | 
				
			||||||
 | 
					        fileManager: FileManager = .default
 | 
				
			||||||
 | 
					    ) throws {
 | 
				
			||||||
 | 
					        self.keyManager = keyManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let modelName = "YobbleDataModel"
 | 
				
			||||||
 | 
					        guard let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd"),
 | 
				
			||||||
 | 
					              let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
 | 
				
			||||||
 | 
					            fatalError("Unable to load Core Data model \(modelName)")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        container = NSPersistentContainer(name: modelName, managedObjectModel: managedObjectModel)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let description = container.persistentStoreDescriptions.first ?? NSPersistentStoreDescription()
 | 
				
			||||||
 | 
					        description.type = NSSQLiteStoreType
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if inMemory {
 | 
				
			||||||
 | 
					            description.url = URL(fileURLWithPath: "/dev/null")
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            description.url = try Self.makeStoreURL(fileManager: fileManager, fileName: "\(modelName).sqlite")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        description.shouldInferMappingModelAutomatically = true
 | 
				
			||||||
 | 
					        description.shouldMigrateStoreAutomatically = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let key: String
 | 
				
			||||||
 | 
					        do {
 | 
				
			||||||
 | 
					            key = try keyManager.currentKey()
 | 
				
			||||||
 | 
					        } catch {
 | 
				
			||||||
 | 
					            throw PersistenceControllerError.encryptionKeyUnavailable
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let pragmas: [String: String] = [
 | 
				
			||||||
 | 
					            "journal_mode": "WAL",
 | 
				
			||||||
 | 
					            "cipher_page_size": "4096",
 | 
				
			||||||
 | 
					            "key": key
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        description.setOption(pragmas as NSDictionary, forKey: NSSQLitePragmasOption)
 | 
				
			||||||
 | 
					        description.setOption(FileProtectionType.completeUntilFirstUserAuthentication as NSObject, forKey: NSPersistentStoreFileProtectionKey)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        container.persistentStoreDescriptions = [description]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        container.loadPersistentStores { _, error in
 | 
				
			||||||
 | 
					            if let error {
 | 
				
			||||||
 | 
					                fatalError("Unresolved error \(error)")
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
 | 
				
			||||||
 | 
					        container.viewContext.automaticallyMergesChangesFromParent = true
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func newBackgroundContext() -> NSManagedObjectContext {
 | 
				
			||||||
 | 
					        container.newBackgroundContext()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Placeholder for a future rekey flow once a password-based key is available.
 | 
				
			||||||
 | 
					    ///
 | 
				
			||||||
 | 
					    /// On iOS 16 with SQLCipher you typically re-encrypt by running `PRAGMA rekey` via a raw
 | 
				
			||||||
 | 
					    /// SQLite handle and then persisting the new key to the key manager. This helper keeps
 | 
				
			||||||
 | 
					    /// the signature in place for when that flow is implemented.
 | 
				
			||||||
 | 
					    func rekeyStore(to newKey: String) throws {
 | 
				
			||||||
 | 
					        throw PersistenceControllerError.rekeyingUnavailable
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static func makeStoreURL(fileManager: FileManager, fileName: String) throws -> URL {
 | 
				
			||||||
 | 
					        guard let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
 | 
				
			||||||
 | 
					            fatalError("Unable to resolve Application Support directory")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if !fileManager.fileExists(atPath: baseURL.path) {
 | 
				
			||||||
 | 
					            try fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return baseURL.appendingPathComponent(fileName)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -19,12 +19,14 @@ enum ChatServiceError: LocalizedError {
 | 
				
			|||||||
final class ChatService {
 | 
					final class ChatService {
 | 
				
			||||||
    private let client: NetworkClient
 | 
					    private let client: NetworkClient
 | 
				
			||||||
    private let decoder: JSONDecoder
 | 
					    private let decoder: JSONDecoder
 | 
				
			||||||
 | 
					    private let cacheWriter: ChatCacheWriter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    init(client: NetworkClient = .shared) {
 | 
					    init(client: NetworkClient = .shared, cacheWriter: ChatCacheWriter = .shared) {
 | 
				
			||||||
        self.client = client
 | 
					        self.client = client
 | 
				
			||||||
        self.decoder = JSONDecoder()
 | 
					        self.decoder = JSONDecoder()
 | 
				
			||||||
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
					        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
				
			||||||
        self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
 | 
					        self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
 | 
				
			||||||
 | 
					        self.cacheWriter = cacheWriter
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    func fetchPrivateChats(
 | 
					    func fetchPrivateChats(
 | 
				
			||||||
@ -42,7 +44,7 @@ final class ChatService {
 | 
				
			|||||||
            method: .get,
 | 
					            method: .get,
 | 
				
			||||||
            query: query,
 | 
					            query: query,
 | 
				
			||||||
            requiresAuth: true
 | 
					            requiresAuth: true
 | 
				
			||||||
        ) { [decoder] result in
 | 
					        ) { [decoder, weak self] result in
 | 
				
			||||||
            switch result {
 | 
					            switch result {
 | 
				
			||||||
            case .success(let response):
 | 
					            case .success(let response):
 | 
				
			||||||
                do {
 | 
					                do {
 | 
				
			||||||
@ -52,7 +54,9 @@ final class ChatService {
 | 
				
			|||||||
                        completion(.failure(ChatServiceError.unexpectedStatus(message)))
 | 
					                        completion(.failure(ChatServiceError.unexpectedStatus(message)))
 | 
				
			||||||
                        return
 | 
					                        return
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    completion(.success(apiResponse.data))
 | 
					                    let chatData = apiResponse.data
 | 
				
			||||||
 | 
					                    self?.cacheWriter.storePrivateChatList(chatData)
 | 
				
			||||||
 | 
					                    completion(.success(chatData))
 | 
				
			||||||
                } catch {
 | 
					                } catch {
 | 
				
			||||||
                    let debugMessage = Self.describeDecodingError(error: error, data: response.data)
 | 
					                    let debugMessage = Self.describeDecodingError(error: error, data: response.data)
 | 
				
			||||||
                    if AppConfig.DEBUG {
 | 
					                    if AppConfig.DEBUG {
 | 
				
			||||||
 | 
				
			|||||||
@ -2287,4 +2287,4 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "version" : "1.0"
 | 
					  "version" : "1.0"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										45
									
								
								yobble/Services/DatabaseEncryptionKeyManager.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								yobble/Services/DatabaseEncryptionKeyManager.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum DatabaseEncryptionKeyError: Error {
 | 
				
			||||||
 | 
					    case keyNotAvailable
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class DatabaseEncryptionKeyManager {
 | 
				
			||||||
 | 
					    static let shared = DatabaseEncryptionKeyManager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private let keychainService: KeychainService
 | 
				
			||||||
 | 
					    private let serviceName = "yobble.database.encryption"
 | 
				
			||||||
 | 
					    private let accountName = "sqlcipher_key"
 | 
				
			||||||
 | 
					    /// Hardcoded dev key used until the user saves their own password-derived key.
 | 
				
			||||||
 | 
					    private let fallbackKey: String
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init(
 | 
				
			||||||
 | 
					        keychainService: KeychainService = .shared,
 | 
				
			||||||
 | 
					        fallbackKey: String = AppConfig.DEFAULT_DATABASE_ENCRYPTION_KEY
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        self.keychainService = keychainService
 | 
				
			||||||
 | 
					        self.fallbackKey = fallbackKey
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func currentKey() throws -> String {
 | 
				
			||||||
 | 
					        if let key = keychainService.get(forKey: accountName, service: serviceName), !key.isEmpty {
 | 
				
			||||||
 | 
					            return key
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        guard !fallbackKey.isEmpty else {
 | 
				
			||||||
 | 
					            throw DatabaseEncryptionKeyError.keyNotAvailable
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return fallbackKey
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func persistKey(_ key: String) {
 | 
				
			||||||
 | 
					        keychainService.save(key, forKey: accountName, service: serviceName)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func clearPersistedKey() {
 | 
				
			||||||
 | 
					        keychainService.delete(forKey: accountName, service: serviceName)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    func hasPersistedKey() -> Bool {
 | 
				
			||||||
 | 
					        keychainService.get(forKey: accountName, service: serviceName) != nil
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -41,6 +41,7 @@ final class SocketService {
 | 
				
			|||||||
    private var heartbeatAckInFlight = false
 | 
					    private var heartbeatAckInFlight = false
 | 
				
			||||||
    private var lastHeartbeatSentAt: Date?
 | 
					    private var lastHeartbeatSentAt: Date?
 | 
				
			||||||
    private var consecutiveHeartbeatMisses = 0
 | 
					    private var consecutiveHeartbeatMisses = 0
 | 
				
			||||||
 | 
					    private var reconnectWorkItem: DispatchWorkItem?
 | 
				
			||||||
    #endif
 | 
					    #endif
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private init() {}
 | 
					    private init() {}
 | 
				
			||||||
@ -126,6 +127,7 @@ final class SocketService {
 | 
				
			|||||||
        currentToken = token
 | 
					        currentToken = token
 | 
				
			||||||
        currentAuthPayload = ["token": token]
 | 
					        currentAuthPayload = ["token": token]
 | 
				
			||||||
        setupSocket(with: token)
 | 
					        setupSocket(with: token)
 | 
				
			||||||
 | 
					        cancelScheduledReconnect()
 | 
				
			||||||
        updateConnectionState(.connecting)
 | 
					        updateConnectionState(.connecting)
 | 
				
			||||||
        socket?.connect(withPayload: currentAuthPayload)
 | 
					        socket?.connect(withPayload: currentAuthPayload)
 | 
				
			||||||
        startHeartbeat()
 | 
					        startHeartbeat()
 | 
				
			||||||
@ -202,11 +204,13 @@ final class SocketService {
 | 
				
			|||||||
        socket.on(clientEvent: .disconnect) { data, _ in
 | 
					        socket.on(clientEvent: .disconnect) { data, _ in
 | 
				
			||||||
            if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
 | 
					            if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
 | 
				
			||||||
            self.updateConnectionState(.disconnected)
 | 
					            self.updateConnectionState(.disconnected)
 | 
				
			||||||
 | 
					            self.scheduleReconnect()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        socket.on(clientEvent: .error) { data, _ in
 | 
					        socket.on(clientEvent: .error) { data, _ in
 | 
				
			||||||
            if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
 | 
					            if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
 | 
				
			||||||
            self.updateConnectionState(.disconnected)
 | 
					            self.updateConnectionState(.disconnected)
 | 
				
			||||||
 | 
					            self.scheduleReconnect()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        socket.on("pong") { [weak self] _, _ in
 | 
					        socket.on("pong") { [weak self] _, _ in
 | 
				
			||||||
@ -223,6 +227,7 @@ final class SocketService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private func disconnectInternal() {
 | 
					    private func disconnectInternal() {
 | 
				
			||||||
        stopHeartbeat()
 | 
					        stopHeartbeat()
 | 
				
			||||||
 | 
					        cancelScheduledReconnect()
 | 
				
			||||||
        socket?.disconnect()
 | 
					        socket?.disconnect()
 | 
				
			||||||
        manager?.disconnect()
 | 
					        manager?.disconnect()
 | 
				
			||||||
        socket = nil
 | 
					        socket = nil
 | 
				
			||||||
@ -321,6 +326,7 @@ final class SocketService {
 | 
				
			|||||||
        consecutiveHeartbeatMisses = 0
 | 
					        consecutiveHeartbeatMisses = 0
 | 
				
			||||||
        heartbeatAckInFlight = false
 | 
					        heartbeatAckInFlight = false
 | 
				
			||||||
        lastHeartbeatSentAt = nil
 | 
					        lastHeartbeatSentAt = nil
 | 
				
			||||||
 | 
					        cancelScheduledReconnect()
 | 
				
			||||||
        updateConnectionState(.connected)
 | 
					        updateConnectionState(.connected)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -339,6 +345,28 @@ final class SocketService {
 | 
				
			|||||||
        socket.disconnect()
 | 
					        socket.disconnect()
 | 
				
			||||||
        socket.connect(withPayload: currentAuthPayload)
 | 
					        socket.connect(withPayload: currentAuthPayload)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func scheduleReconnect(after delay: TimeInterval = 5) {
 | 
				
			||||||
 | 
					        cancelScheduledReconnect()
 | 
				
			||||||
 | 
					        let workItem = DispatchWorkItem { [weak self] in
 | 
				
			||||||
 | 
					            guard let self else { return }
 | 
				
			||||||
 | 
					            if self.refreshAuthTokenIfNeeded(disconnectIfMissing: true) { return }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if let socket = self.socket {
 | 
				
			||||||
 | 
					                self.updateConnectionState(.connecting)
 | 
				
			||||||
 | 
					                socket.connect(withPayload: self.currentAuthPayload)
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                self.connectForCurrentUser()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        reconnectWorkItem = workItem
 | 
				
			||||||
 | 
					        syncQueue.asyncAfter(deadline: .now() + delay, execute: workItem)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func cancelScheduledReconnect() {
 | 
				
			||||||
 | 
					        reconnectWorkItem?.cancel()
 | 
				
			||||||
 | 
					        reconnectWorkItem = nil
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    #else
 | 
					    #else
 | 
				
			||||||
    private func disconnectInternal() { }
 | 
					    private func disconnectInternal() { }
 | 
				
			||||||
    #endif
 | 
					    #endif
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,7 @@ import UIKit
 | 
				
			|||||||
#endif
 | 
					#endif
 | 
				
			||||||
 | 
					
 | 
				
			||||||
struct ChatsTab: View {
 | 
					struct ChatsTab: View {
 | 
				
			||||||
    var currentUserId: String?
 | 
					    @ObservedObject private var loginViewModel: LoginViewModel
 | 
				
			||||||
    @Binding var searchRevealProgress: CGFloat
 | 
					    @Binding var searchRevealProgress: CGFloat
 | 
				
			||||||
    @Binding var searchText: String
 | 
					    @Binding var searchText: String
 | 
				
			||||||
    private let searchService = SearchService()
 | 
					    private let searchService = SearchService()
 | 
				
			||||||
@ -33,8 +33,13 @@ struct ChatsTab: View {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private let searchRevealDistance: CGFloat = 90
 | 
					    private let searchRevealDistance: CGFloat = 90
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
 | 
					    private var currentUserId: String? {
 | 
				
			||||||
        self.currentUserId = currentUserId
 | 
					        let userId = loginViewModel.userId
 | 
				
			||||||
 | 
					        return userId.isEmpty ? nil : userId
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
 | 
				
			||||||
 | 
					        self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
 | 
				
			||||||
        self._searchRevealProgress = searchRevealProgress
 | 
					        self._searchRevealProgress = searchRevealProgress
 | 
				
			||||||
        self._searchText = searchText
 | 
					        self._searchText = searchText
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -111,11 +116,11 @@ struct ChatsTab: View {
 | 
				
			|||||||
                            Text(message)
 | 
					                            Text(message)
 | 
				
			||||||
                                .font(.subheadline)
 | 
					                                .font(.subheadline)
 | 
				
			||||||
                                .foregroundColor(.orange)
 | 
					                                .foregroundColor(.orange)
 | 
				
			||||||
                            Spacer(minLength: 0)
 | 
					//                            Spacer(minLength: 0)
 | 
				
			||||||
                            Button(action: { viewModel.refresh() }) {
 | 
					//                            Button(action: triggerChatsReload) {
 | 
				
			||||||
                                Text(NSLocalizedString("Обновить", comment: ""))
 | 
					//                                Text(NSLocalizedString("Обновить", comment: ""))
 | 
				
			||||||
                                    .font(.subheadline)
 | 
					//                                    .font(.subheadline)
 | 
				
			||||||
                            }
 | 
					//                            }
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                        .padding(.vertical, 4)
 | 
					                        .padding(.vertical, 4)
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
@ -139,9 +144,10 @@ struct ChatsTab: View {
 | 
				
			|||||||
                        globalSearchContent
 | 
					                        globalSearchContent
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                } else {
 | 
					                } else {
 | 
				
			||||||
                    if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
 | 
					//                    if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
 | 
				
			||||||
                        errorState(message: message)
 | 
					//                        errorState(message: message)
 | 
				
			||||||
                    } else if viewModel.chats.isEmpty {
 | 
					//                    } else
 | 
				
			||||||
 | 
					                    if viewModel.chats.isEmpty {
 | 
				
			||||||
                        emptyState
 | 
					                        emptyState
 | 
				
			||||||
                    } else {
 | 
					                    } else {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -296,9 +302,11 @@ struct ChatsTab: View {
 | 
				
			|||||||
    private var globalSearchContent: some View {
 | 
					    private var globalSearchContent: some View {
 | 
				
			||||||
        if isGlobalSearchLoading {
 | 
					        if isGlobalSearchLoading {
 | 
				
			||||||
            globalSearchLoadingRow
 | 
					            globalSearchLoadingRow
 | 
				
			||||||
        } else if let error = globalSearchError {
 | 
					        } else
 | 
				
			||||||
            globalSearchErrorRow(message: error)
 | 
					//        if let error = globalSearchError {
 | 
				
			||||||
        } else if globalSearchResults.isEmpty {
 | 
					//            globalSearchErrorRow(message: error)
 | 
				
			||||||
 | 
					//        } else
 | 
				
			||||||
 | 
					        if globalSearchResults.isEmpty {
 | 
				
			||||||
            globalSearchEmptyRow
 | 
					            globalSearchEmptyRow
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            ForEach(globalSearchResults) { user in
 | 
					            ForEach(globalSearchResults) { user in
 | 
				
			||||||
@ -341,7 +349,7 @@ struct ChatsTab: View {
 | 
				
			|||||||
                .font(.body)
 | 
					                .font(.body)
 | 
				
			||||||
                .multilineTextAlignment(.center)
 | 
					                .multilineTextAlignment(.center)
 | 
				
			||||||
                .foregroundColor(.primary)
 | 
					                .foregroundColor(.primary)
 | 
				
			||||||
            Button(action: { viewModel.loadInitialChats(force: true) }) {
 | 
					            Button(action: triggerChatsReload) {
 | 
				
			||||||
                Text(NSLocalizedString("Повторить", comment: ""))
 | 
					                Text(NSLocalizedString("Повторить", comment: ""))
 | 
				
			||||||
                    .font(.headline)
 | 
					                    .font(.headline)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
@ -359,7 +367,7 @@ struct ChatsTab: View {
 | 
				
			|||||||
            Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
 | 
					            Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
 | 
				
			||||||
                .font(.body)
 | 
					                .font(.body)
 | 
				
			||||||
                .foregroundColor(.secondary)
 | 
					                .foregroundColor(.secondary)
 | 
				
			||||||
            Button(action: { viewModel.refresh() }) {
 | 
					            Button(action: triggerChatsReload) {
 | 
				
			||||||
                Text(NSLocalizedString("Обновить", comment: ""))
 | 
					                Text(NSLocalizedString("Обновить", comment: ""))
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            .buttonStyle(.bordered)
 | 
					            .buttonStyle(.bordered)
 | 
				
			||||||
@ -381,6 +389,13 @@ struct ChatsTab: View {
 | 
				
			|||||||
        .listRowSeparator(.hidden)
 | 
					        .listRowSeparator(.hidden)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func triggerChatsReload() {
 | 
				
			||||||
 | 
					        if loginViewModel.chatLoadingState != .loading {
 | 
				
			||||||
 | 
					            loginViewModel.chatLoadingState = .loading
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        viewModel.loadInitialChats(force: true)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @ViewBuilder
 | 
					    @ViewBuilder
 | 
				
			||||||
    private func chatRowItem(for chat: PrivateChatListItem) -> some View {
 | 
					    private func chatRowItem(for chat: PrivateChatListItem) -> some View {
 | 
				
			||||||
        Button {
 | 
					        Button {
 | 
				
			||||||
@ -1135,10 +1150,15 @@ struct ChatsTab_Previews: PreviewProvider {
 | 
				
			|||||||
    struct Wrapper: View {
 | 
					    struct Wrapper: View {
 | 
				
			||||||
        @State private var progress: CGFloat = 1
 | 
					        @State private var progress: CGFloat = 1
 | 
				
			||||||
        @State private var searchText: String = ""
 | 
					        @State private var searchText: String = ""
 | 
				
			||||||
 | 
					        @StateObject private var loginViewModel = LoginViewModel()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var body: some View {
 | 
					        var body: some View {
 | 
				
			||||||
            ChatsTab(searchRevealProgress: $progress, searchText: $searchText)
 | 
					            ChatsTab(
 | 
				
			||||||
                .environmentObject(ThemeManager())
 | 
					                loginViewModel: loginViewModel,
 | 
				
			||||||
 | 
					                searchRevealProgress: $progress,
 | 
				
			||||||
 | 
					                searchText: $searchText
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .environmentObject(ThemeManager())
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -53,7 +53,7 @@ struct MainView: View {
 | 
				
			|||||||
                            .opacity(selectedTab == 1 ? 1 : 0)
 | 
					                            .opacity(selectedTab == 1 ? 1 : 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        ChatsTab(
 | 
					                        ChatsTab(
 | 
				
			||||||
                            currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId,
 | 
					                            loginViewModel: viewModel,
 | 
				
			||||||
                            searchRevealProgress: $chatSearchRevealProgress,
 | 
					                            searchRevealProgress: $chatSearchRevealProgress,
 | 
				
			||||||
                            searchText: $chatSearchText
 | 
					                            searchText: $chatSearchText
 | 
				
			||||||
                        )
 | 
					                        )
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,10 @@ struct AppConfig {
 | 
				
			|||||||
    static let USER_AGENT = "yobble ios"
 | 
					    static let USER_AGENT = "yobble ios"
 | 
				
			||||||
    static let APP_BUILD = "appstore" // appstore / freestore
 | 
					    static let APP_BUILD = "appstore" // appstore / freestore
 | 
				
			||||||
    static let APP_VERSION = "0.1"
 | 
					    static let APP_VERSION = "0.1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static let DISABLE_DB = false
 | 
				
			||||||
 | 
					    /// Fallback SQLCipher key used until the user sets an application password.
 | 
				
			||||||
 | 
					    static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
struct AppInfo {
 | 
					struct AppInfo {
 | 
				
			||||||
 | 
				
			|||||||
@ -6,11 +6,13 @@
 | 
				
			|||||||
//
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import SwiftUI
 | 
					import SwiftUI
 | 
				
			||||||
 | 
					import CoreData
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@main
 | 
					@main
 | 
				
			||||||
struct yobbleApp: App {
 | 
					struct yobbleApp: App {
 | 
				
			||||||
    @StateObject private var themeManager = ThemeManager()
 | 
					    @StateObject private var themeManager = ThemeManager()
 | 
				
			||||||
    @StateObject private var viewModel = LoginViewModel()
 | 
					    @StateObject private var viewModel = LoginViewModel()
 | 
				
			||||||
 | 
					    private let persistenceController = PersistenceController.shared
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    var body: some Scene {
 | 
					    var body: some Scene {
 | 
				
			||||||
        WindowGroup {
 | 
					        WindowGroup {
 | 
				
			||||||
@ -25,6 +27,7 @@ struct yobbleApp: App {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
            .environmentObject(themeManager)
 | 
					            .environmentObject(themeManager)
 | 
				
			||||||
            .preferredColorScheme(themeManager.theme.colorScheme)
 | 
					            .preferredColorScheme(themeManager.theme.colorScheme)
 | 
				
			||||||
 | 
					            .environment(\.managedObjectContext, persistenceController.viewContext)
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user