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? {
|
||||
if viewModel.socketState != .connected {
|
||||
return NSLocalizedString("Подключение", comment: "")
|
||||
}
|
||||
if viewModel.chatLoadingState == .loading {
|
||||
return NSLocalizedString("Загрузка чатов", comment: "")
|
||||
}
|
||||
if viewModel.socketState == .connecting {
|
||||
return NSLocalizedString("Подключение", comment: "")
|
||||
}
|
||||
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 {
|
||||
private let client: NetworkClient
|
||||
private let decoder: JSONDecoder
|
||||
private let cacheWriter: ChatCacheWriter
|
||||
|
||||
init(client: NetworkClient = .shared) {
|
||||
init(client: NetworkClient = .shared, cacheWriter: ChatCacheWriter = .shared) {
|
||||
self.client = client
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
||||
self.cacheWriter = cacheWriter
|
||||
}
|
||||
|
||||
func fetchPrivateChats(
|
||||
@ -42,7 +44,7 @@ final class ChatService {
|
||||
method: .get,
|
||||
query: query,
|
||||
requiresAuth: true
|
||||
) { [decoder] result in
|
||||
) { [decoder, weak self] result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
do {
|
||||
@ -52,7 +54,9 @@ final class ChatService {
|
||||
completion(.failure(ChatServiceError.unexpectedStatus(message)))
|
||||
return
|
||||
}
|
||||
completion(.success(apiResponse.data))
|
||||
let chatData = apiResponse.data
|
||||
self?.cacheWriter.storePrivateChatList(chatData)
|
||||
completion(.success(chatData))
|
||||
} catch {
|
||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||||
if AppConfig.DEBUG {
|
||||
|
||||
@ -2287,4 +2287,4 @@
|
||||
}
|
||||
},
|
||||
"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 lastHeartbeatSentAt: Date?
|
||||
private var consecutiveHeartbeatMisses = 0
|
||||
private var reconnectWorkItem: DispatchWorkItem?
|
||||
#endif
|
||||
|
||||
private init() {}
|
||||
@ -126,6 +127,7 @@ final class SocketService {
|
||||
currentToken = token
|
||||
currentAuthPayload = ["token": token]
|
||||
setupSocket(with: token)
|
||||
cancelScheduledReconnect()
|
||||
updateConnectionState(.connecting)
|
||||
socket?.connect(withPayload: currentAuthPayload)
|
||||
startHeartbeat()
|
||||
@ -202,11 +204,13 @@ final class SocketService {
|
||||
socket.on(clientEvent: .disconnect) { data, _ in
|
||||
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
|
||||
self.updateConnectionState(.disconnected)
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
|
||||
socket.on(clientEvent: .error) { data, _ in
|
||||
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
|
||||
self.updateConnectionState(.disconnected)
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
|
||||
socket.on("pong") { [weak self] _, _ in
|
||||
@ -223,6 +227,7 @@ final class SocketService {
|
||||
|
||||
private func disconnectInternal() {
|
||||
stopHeartbeat()
|
||||
cancelScheduledReconnect()
|
||||
socket?.disconnect()
|
||||
manager?.disconnect()
|
||||
socket = nil
|
||||
@ -321,6 +326,7 @@ final class SocketService {
|
||||
consecutiveHeartbeatMisses = 0
|
||||
heartbeatAckInFlight = false
|
||||
lastHeartbeatSentAt = nil
|
||||
cancelScheduledReconnect()
|
||||
updateConnectionState(.connected)
|
||||
}
|
||||
|
||||
@ -339,6 +345,28 @@ final class SocketService {
|
||||
socket.disconnect()
|
||||
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
|
||||
private func disconnectInternal() { }
|
||||
#endif
|
||||
|
||||
@ -11,7 +11,7 @@ import UIKit
|
||||
#endif
|
||||
|
||||
struct ChatsTab: View {
|
||||
var currentUserId: String?
|
||||
@ObservedObject private var loginViewModel: LoginViewModel
|
||||
@Binding var searchRevealProgress: CGFloat
|
||||
@Binding var searchText: String
|
||||
private let searchService = SearchService()
|
||||
@ -33,8 +33,13 @@ struct ChatsTab: View {
|
||||
|
||||
private let searchRevealDistance: CGFloat = 90
|
||||
|
||||
init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
|
||||
self.currentUserId = currentUserId
|
||||
private var currentUserId: String? {
|
||||
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._searchText = searchText
|
||||
}
|
||||
@ -111,11 +116,11 @@ struct ChatsTab: View {
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.orange)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: { viewModel.refresh() }) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
.font(.subheadline)
|
||||
}
|
||||
// Spacer(minLength: 0)
|
||||
// Button(action: triggerChatsReload) {
|
||||
// Text(NSLocalizedString("Обновить", comment: ""))
|
||||
// .font(.subheadline)
|
||||
// }
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
@ -139,9 +144,10 @@ struct ChatsTab: View {
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||||
errorState(message: message)
|
||||
} else if viewModel.chats.isEmpty {
|
||||
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||||
// errorState(message: message)
|
||||
// } else
|
||||
if viewModel.chats.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
@ -296,9 +302,11 @@ struct ChatsTab: View {
|
||||
private var globalSearchContent: some View {
|
||||
if isGlobalSearchLoading {
|
||||
globalSearchLoadingRow
|
||||
} else if let error = globalSearchError {
|
||||
globalSearchErrorRow(message: error)
|
||||
} else if globalSearchResults.isEmpty {
|
||||
} else
|
||||
// if let error = globalSearchError {
|
||||
// globalSearchErrorRow(message: error)
|
||||
// } else
|
||||
if globalSearchResults.isEmpty {
|
||||
globalSearchEmptyRow
|
||||
} else {
|
||||
ForEach(globalSearchResults) { user in
|
||||
@ -341,7 +349,7 @@ struct ChatsTab: View {
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.primary)
|
||||
Button(action: { viewModel.loadInitialChats(force: true) }) {
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Повторить", comment: ""))
|
||||
.font(.headline)
|
||||
}
|
||||
@ -359,7 +367,7 @@ struct ChatsTab: View {
|
||||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: { viewModel.refresh() }) {
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@ -381,6 +389,13 @@ struct ChatsTab: View {
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
private func triggerChatsReload() {
|
||||
if loginViewModel.chatLoadingState != .loading {
|
||||
loginViewModel.chatLoadingState = .loading
|
||||
}
|
||||
viewModel.loadInitialChats(force: true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
||||
Button {
|
||||
@ -1135,10 +1150,15 @@ struct ChatsTab_Previews: PreviewProvider {
|
||||
struct Wrapper: View {
|
||||
@State private var progress: CGFloat = 1
|
||||
@State private var searchText: String = ""
|
||||
@StateObject private var loginViewModel = LoginViewModel()
|
||||
|
||||
var body: some View {
|
||||
ChatsTab(searchRevealProgress: $progress, searchText: $searchText)
|
||||
.environmentObject(ThemeManager())
|
||||
ChatsTab(
|
||||
loginViewModel: loginViewModel,
|
||||
searchRevealProgress: $progress,
|
||||
searchText: $searchText
|
||||
)
|
||||
.environmentObject(ThemeManager())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ struct MainView: View {
|
||||
.opacity(selectedTab == 1 ? 1 : 0)
|
||||
|
||||
ChatsTab(
|
||||
currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId,
|
||||
loginViewModel: viewModel,
|
||||
searchRevealProgress: $chatSearchRevealProgress,
|
||||
searchText: $chatSearchText
|
||||
)
|
||||
|
||||
@ -11,6 +11,10 @@ struct AppConfig {
|
||||
static let USER_AGENT = "yobble ios"
|
||||
static let APP_BUILD = "appstore" // appstore / freestore
|
||||
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 {
|
||||
|
||||
@ -6,11 +6,13 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
@main
|
||||
struct yobbleApp: App {
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
private let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@ -25,6 +27,7 @@ struct yobbleApp: App {
|
||||
}
|
||||
.environmentObject(themeManager)
|
||||
.preferredColorScheme(themeManager.theme.colorScheme)
|
||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user