Compare commits
No commits in common. "cc32d7acad64a79abcbf8c53cb5a7a0b669f2f01" and "7dc78edb02a0ed814feaed238924b598e4456cca" have entirely different histories.
cc32d7acad
...
7dc78edb02
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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,14 +19,12 @@ 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, cacheWriter: ChatCacheWriter = .shared) {
|
init(client: NetworkClient = .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(
|
||||||
@ -44,7 +42,7 @@ final class ChatService {
|
|||||||
method: .get,
|
method: .get,
|
||||||
query: query,
|
query: query,
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
) { [decoder, weak self] result in
|
) { [decoder] result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let response):
|
case .success(let response):
|
||||||
do {
|
do {
|
||||||
@ -54,9 +52,7 @@ final class ChatService {
|
|||||||
completion(.failure(ChatServiceError.unexpectedStatus(message)))
|
completion(.failure(ChatServiceError.unexpectedStatus(message)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let chatData = apiResponse.data
|
completion(.success(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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
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,7 +41,6 @@ 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() {}
|
||||||
@ -127,7 +126,6 @@ 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()
|
||||||
@ -204,13 +202,11 @@ 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
|
||||||
@ -227,7 +223,6 @@ final class SocketService {
|
|||||||
|
|
||||||
private func disconnectInternal() {
|
private func disconnectInternal() {
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
cancelScheduledReconnect()
|
|
||||||
socket?.disconnect()
|
socket?.disconnect()
|
||||||
manager?.disconnect()
|
manager?.disconnect()
|
||||||
socket = nil
|
socket = nil
|
||||||
@ -326,7 +321,6 @@ final class SocketService {
|
|||||||
consecutiveHeartbeatMisses = 0
|
consecutiveHeartbeatMisses = 0
|
||||||
heartbeatAckInFlight = false
|
heartbeatAckInFlight = false
|
||||||
lastHeartbeatSentAt = nil
|
lastHeartbeatSentAt = nil
|
||||||
cancelScheduledReconnect()
|
|
||||||
updateConnectionState(.connected)
|
updateConnectionState(.connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,28 +339,6 @@ 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 {
|
||||||
@ObservedObject private var loginViewModel: LoginViewModel
|
var currentUserId: String?
|
||||||
@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,13 +33,8 @@ struct ChatsTab: View {
|
|||||||
|
|
||||||
private let searchRevealDistance: CGFloat = 90
|
private let searchRevealDistance: CGFloat = 90
|
||||||
|
|
||||||
private var currentUserId: String? {
|
init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
|
||||||
let userId = loginViewModel.userId
|
self.currentUserId = currentUserId
|
||||||
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
|
||||||
}
|
}
|
||||||
@ -116,11 +111,11 @@ struct ChatsTab: View {
|
|||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
// Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
// Button(action: triggerChatsReload) {
|
Button(action: { viewModel.refresh() }) {
|
||||||
// Text(NSLocalizedString("Обновить", comment: ""))
|
Text(NSLocalizedString("Обновить", comment: ""))
|
||||||
// .font(.subheadline)
|
.font(.subheadline)
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
@ -144,10 +139,9 @@ 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
|
} else if viewModel.chats.isEmpty {
|
||||||
if viewModel.chats.isEmpty {
|
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@ -302,11 +296,9 @@ struct ChatsTab: View {
|
|||||||
private var globalSearchContent: some View {
|
private var globalSearchContent: some View {
|
||||||
if isGlobalSearchLoading {
|
if isGlobalSearchLoading {
|
||||||
globalSearchLoadingRow
|
globalSearchLoadingRow
|
||||||
} else
|
} else if let error = globalSearchError {
|
||||||
// if let error = globalSearchError {
|
globalSearchErrorRow(message: error)
|
||||||
// globalSearchErrorRow(message: error)
|
} else if globalSearchResults.isEmpty {
|
||||||
// } else
|
|
||||||
if globalSearchResults.isEmpty {
|
|
||||||
globalSearchEmptyRow
|
globalSearchEmptyRow
|
||||||
} else {
|
} else {
|
||||||
ForEach(globalSearchResults) { user in
|
ForEach(globalSearchResults) { user in
|
||||||
@ -349,7 +341,7 @@ struct ChatsTab: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
Button(action: triggerChatsReload) {
|
Button(action: { viewModel.loadInitialChats(force: true) }) {
|
||||||
Text(NSLocalizedString("Повторить", comment: ""))
|
Text(NSLocalizedString("Повторить", comment: ""))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
@ -367,7 +359,7 @@ struct ChatsTab: View {
|
|||||||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button(action: triggerChatsReload) {
|
Button(action: { viewModel.refresh() }) {
|
||||||
Text(NSLocalizedString("Обновить", comment: ""))
|
Text(NSLocalizedString("Обновить", comment: ""))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@ -389,13 +381,6 @@ 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 {
|
||||||
@ -1150,15 +1135,10 @@ 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(
|
ChatsTab(searchRevealProgress: $progress, searchText: $searchText)
|
||||||
loginViewModel: loginViewModel,
|
.environmentObject(ThemeManager())
|
||||||
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(
|
||||||
loginViewModel: viewModel,
|
currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId,
|
||||||
searchRevealProgress: $chatSearchRevealProgress,
|
searchRevealProgress: $chatSearchRevealProgress,
|
||||||
searchText: $chatSearchText
|
searchText: $chatSearchText
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,10 +11,6 @@ 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,13 +6,11 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
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 {
|
||||||
@ -27,7 +25,6 @@ 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