diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index dc822c8..26ce1a8 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -26,6 +26,7 @@ enum RuntimeEnvironment { @MainActor final class AppModel: ObservableObject { @Published private(set) var account: AccountConfiguration? + @Published private(set) var accounts: [AccountConfiguration] = [] @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected( reason: nil) private(set) var conversations: [Conversation] = [] { @@ -351,7 +352,10 @@ final class AppModel: ObservableObject { guard !RuntimeEnvironment.isRunningTests, !RuntimeEnvironment.isRunningPreviews else { return } - guard let saved = preferences.load() else { return } + let configured = preferences.loadAccounts() + accounts = configured + if !configured.isEmpty { try? preferences.saveAccounts(configured) } + guard let saved = preferences.load() ?? configured.first else { return } prepareStore(for: saved.normalizedJID) account = saved globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID) @@ -432,7 +436,9 @@ final class AppModel: ObservableObject { mamCheckpoints: mamCheckpoints ) try credentials.save(password: password, for: account.normalizedJID) + accounts = accounts.filter { $0.normalizedJID != account.normalizedJID } + [account] try preferences.save(account) + try preferences.saveAccounts(accounts) await notifications.requestAuthorization() } catch { await xmpp.disconnect() @@ -450,7 +456,10 @@ final class AppModel: ObservableObject { func signOut(forgetHistory: Bool = false) async { let oldAccount = account await xmpp.disconnect() - preferences.clear() + if let oldAccount { + accounts.removeAll { $0.normalizedJID == oldAccount.normalizedJID } + try? preferences.saveAccounts(accounts) + } if let oldAccount { try? credentials.deletePassword(for: oldAccount.normalizedJID) if forgetHistory { @@ -492,6 +501,49 @@ final class AppModel: ObservableObject { resetTypingState() resetMediaSendActivity() resetMediaPreviews() + if let next = accounts.first { + await switchAccount(to: next) + } + } + + /// Adds an account and makes it the active connection. The login screen + /// uses the regular sign-in path, so credentials and per-account archives + /// are handled exactly as they are for the first account. + func addAccount(account: AccountConfiguration, password: String) async { + await signIn(account: account, password: password) + } + + func switchAccount(to target: AccountConfiguration) async { + guard target.normalizedJID != account?.normalizedJID else { return } + let password: String + do { + guard let stored = try credentials.password(for: target.normalizedJID) else { + errorMessage = "Для этого аккаунта нет пароля в Keychain. Войдите снова." + return + } + password = stored + } catch { + errorMessage = "Для этого аккаунта нет пароля в Keychain. Войдите снова." + return + } + await xmpp.disconnect() + prepareStore(for: target.normalizedJID) + account = target + try? preferences.save(target) + globalEncryptionEnabled = preferences.encryptionEnabled(for: target.normalizedJID) + typingIndicatorsEnabled = preferences.chatStatesEnabled(for: target.normalizedJID) + xmpp.setChatStatesEnabled(typingIndicatorsEnabled) + await loadArchive(for: target.normalizedJID) + do { + try await xmpp.connect( + account: target, + password: password, + archiveCheckpoint: durableArchiveSyncCheckpoint, + mamCheckpoints: mamCheckpoints + ) + } catch { + errorMessage = error.localizedDescription + } } func reconnect() async { diff --git a/Sources/Shared/Models/Conversation.swift b/Sources/Shared/Models/Conversation.swift index 672f208..a662e77 100644 --- a/Sources/Shared/Models/Conversation.swift +++ b/Sources/Shared/Models/Conversation.swift @@ -78,6 +78,15 @@ final class Conversation { } } +/// A row in the cross-account inbox. JID alone is not a stable identity when +/// two logged-in accounts have the same contact or a self-chat. +struct AccountConversation: Identifiable { + let account: AccountConfiguration + let conversation: Conversation + + var id: String { "\(account.normalizedJID)|\(conversation.jid)" } +} + private extension String { var nilIfBlank: String? { trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self diff --git a/Sources/Shared/Persistence/AccountPreferences.swift b/Sources/Shared/Persistence/AccountPreferences.swift index 5485161..3fdb73c 100644 --- a/Sources/Shared/Persistence/AccountPreferences.swift +++ b/Sources/Shared/Persistence/AccountPreferences.swift @@ -3,6 +3,7 @@ import Foundation final class AccountPreferences { private let defaults: UserDefaults private let accountKey = "luma.active-account.v1" + private let accountsKey = "luma.accounts.v1" private let encryptionSettingsKey = "luma.encryption-settings.v1" private let chatStateSettingsKey = "luma.chat-state-settings.v1" @@ -15,6 +16,39 @@ final class AccountPreferences { return try? JSONDecoder().decode(AccountConfiguration.self, from: data) } + /// Returns every configured account. The original single-account key is + /// migrated lazily so existing installations keep working. + func loadAccounts() -> [AccountConfiguration] { + if let data = defaults.data(forKey: accountsKey), + let accounts = try? JSONDecoder().decode([AccountConfiguration].self, from: data) { + return accounts + } + guard let account = load() else { return [] } + return [account] + } + + func saveAccounts(_ accounts: [AccountConfiguration]) throws { + let normalized = accounts.reduce(into: [String: AccountConfiguration]()) { result, account in + result[account.normalizedJID] = account + }.values.sorted { $0.normalizedJID < $1.normalizedJID } + defaults.set(try JSONEncoder().encode(normalized), forKey: accountsKey) + if let active = load(), normalized.contains(where: { $0.normalizedJID == active.normalizedJID }) { + try save(active) + } else if let first = normalized.first { + try save(first) + } else { + clear() + } + } + + func add(_ account: AccountConfiguration) throws { + try saveAccounts(loadAccounts() + [account]) + } + + func remove(jid: String) throws { + try saveAccounts(loadAccounts().filter { $0.normalizedJID != jid.lowercased() }) + } + func save(_ account: AccountConfiguration) throws { let data = try JSONEncoder().encode(account) defaults.set(data, forKey: accountKey) diff --git a/Sources/Shared/UI/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift index ba156c1..cf22833 100644 --- a/Sources/Shared/UI/MainTabView.swift +++ b/Sources/Shared/UI/MainTabView.swift @@ -157,7 +157,20 @@ private struct ChatsTab: View { SortDescriptor(\Conversation.lastActivity, order: .reverse) ] ) - private var conversations: [Conversation] + private var storedConversations: [Conversation] + + private var conversations: [AccountConversation] { + let source = model.conversations.isEmpty ? storedConversations : model.conversations + guard let account = model.account else { + return source.map { + AccountConversation( + account: AccountConfiguration(jid: "preview@example.org"), + conversation: $0 + ) + } + } + return source.map { AccountConversation(account: account, conversation: $0) } + } var body: some View { NavigationStack { @@ -223,7 +236,7 @@ private struct ChatsTab: View { } - private var filteredConversations: [Conversation] { + private var filteredConversations: [AccountConversation] { let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch) @@ -232,21 +245,37 @@ private struct ChatsTab: View { private var chatList: some View { List { - ForEach(filteredConversations) { conversation in + ForEach(filteredConversations) { item in + let conversation = item.conversation NavigationLink { ChatView(model: model, conversation: conversation) - .id(conversation.jid) + .id(item.id) .onAppear { - model.selectConversation(id: conversation.jid) + if item.account.normalizedJID != model.account?.normalizedJID { + Task { + await model.switchAccount(to: item.account) + model.selectConversation(id: conversation.jid) + } + } else { + model.selectConversation(id: conversation.jid) + } } } label: { - ConversationRow( - conversation: conversation, - imageData: model.avatarData(for: conversation.jid), - isEncrypted: model.encryptionEnabled( - for: conversation.jid + VStack(alignment: .leading, spacing: 2) { + ConversationRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid), + isEncrypted: model.encryptionEnabled( + for: conversation.jid + ) ) - ) + if model.accounts.count > 1 { + Text(item.account.normalizedJID) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.leading, 52) + } + } } .listRowInsets( EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) @@ -279,9 +308,11 @@ private struct ChatsTab: View { } } - private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) + private func conversationSort(_ lhs: AccountConversation, _ rhs: AccountConversation) -> Bool { + let lhs = lhs.conversation + let rhs = rhs.conversation if lhs.isPinned != rhs.isPinned { return lhs.isPinned } if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity @@ -290,9 +321,11 @@ private struct ChatsTab: View { == .orderedAscending } - private func matchesSearch(_ conversation: Conversation) -> Bool { - conversation.displayName.localizedCaseInsensitiveContains(searchText) + private func matchesSearch(_ item: AccountConversation) -> Bool { + let conversation = item.conversation + return conversation.displayName.localizedCaseInsensitiveContains(searchText) || conversation.jid.localizedCaseInsensitiveContains(searchText) + || item.account.normalizedJID.localizedCaseInsensitiveContains(searchText) } } @@ -664,6 +697,19 @@ private struct ContactRow: View { ) private var conversations: [Conversation] + private var chatItems: [AccountConversation] { + let source = model.conversations.isEmpty ? conversations : model.conversations + guard let account = model.account else { + return source.map { + AccountConversation( + account: AccountConfiguration(jid: "preview@example.org"), + conversation: $0 + ) + } + } + return source.map { AccountConversation(account: account, conversation: $0) } + } + var body: some View { NavigationSplitView { sidebar @@ -822,13 +868,14 @@ private struct ContactRow: View { @ViewBuilder private var chatRows: some View { - ForEach(filteredChats) { conversation in + ForEach(filteredChats) { item in + let conversation = item.conversation ConversationRow( conversation: conversation, imageData: model.avatarData(for: conversation.jid), isEncrypted: model.encryptionEnabled(for: conversation.jid) ) - .tag(conversation.jid) + .tag(item.id) .contextMenu { if conversation.isGroup { Button("Удалить", role: .destructive) { @@ -904,7 +951,15 @@ private struct ContactRow: View { ChatView(model: model, conversation: conversation) .id(conversation.jid) .onAppear { - model.selectConversation(id: conversation.jid) + if let item = chatItems.first(where: { $0.id == selectedJID }), + item.account.normalizedJID != model.account?.normalizedJID { + Task { + await model.switchAccount(to: item.account) + model.selectConversation(id: conversation.jid) + } + } else { + model.selectConversation(id: conversation.jid) + } } } else { ContentUnavailableView( @@ -934,37 +989,39 @@ private struct ContactRow: View { else { return nil } - return conversations.first(where: { - $0.jid == message.conversationID - }) + return chatItems.first(where: { $0.conversation.jid == message.conversationID })?.conversation } - return conversations.first(where: { $0.jid == selectedJID }) + return chatItems.first(where: { $0.id == selectedJID || $0.conversation.jid == selectedJID })?.conversation } private var defaultConversation: Conversation? { - mode == .chats ? sortedChats.first : nil + mode == .chats ? sortedChats.first?.conversation : nil } // MARK: Filtering and sorting - private var sortedChats: [Conversation] { - conversations.sorted(by: conversationSort) + private var sortedChats: [AccountConversation] { + chatItems.sorted(by: conversationSort) } - private var filteredChats: [Conversation] { + private var filteredChats: [AccountConversation] { searchText.isEmpty ? sortedChats : sortedChats.filter(matchesChat) } - private func matchesChat(_ conversation: Conversation) -> Bool { + private func matchesChat(_ item: AccountConversation) -> Bool { + let conversation = item.conversation conversation.displayName.localizedCaseInsensitiveContains( searchText ) || conversation.jid.localizedCaseInsensitiveContains(searchText) + || item.account.normalizedJID.localizedCaseInsensitiveContains(searchText) } - private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) + private func conversationSort(_ lhs: AccountConversation, _ rhs: AccountConversation) -> Bool { + let lhs = lhs.conversation + let rhs = rhs.conversation if lhs.isPinned != rhs.isPinned { return lhs.isPinned } if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity @@ -980,14 +1037,14 @@ private struct ContactRow: View { .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } - .filter { searchText.isEmpty || matchesChat($0) } + .filter { searchText.isEmpty || matchesChat(AccountConversation(account: model.account ?? AccountConfiguration(jid: "preview@example.org"), conversation: $0)) } .sorted(by: contactSort) } private var filteredGroups: [Conversation] { conversations .filter { $0.isGroup } - .filter { searchText.isEmpty || matchesChat($0) } + .filter { searchText.isEmpty || matchesChat(AccountConversation(account: model.account ?? AccountConfiguration(jid: "preview@example.org"), conversation: $0)) } .sorted(by: contactSort) } @@ -1018,13 +1075,13 @@ private struct ContactRow: View { selectedJID = nil searchText = "" if newMode == .chats { - selectedJID = sortedChats.first?.jid + selectedJID = sortedChats.first?.id } } private func ensureSelection() { if mode == .chats, selectedJID == nil { - selectedJID = sortedChats.first?.jid + selectedJID = sortedChats.first?.id } } } diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 0a158c1..cbb1c0f 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -12,6 +12,7 @@ struct SettingsView: View { @State private var forgetHistory = false @State private var avatarItem: PhotosPickerItem? @State private var passcodeSheetMode: AppLockPasscodeSheet.Mode? + @State private var showingAddAccount = false var body: some View { #if os(iOS) @@ -19,6 +20,7 @@ struct SettingsView: View { Form { formcontent() } + .sheet(isPresented: $showingAddAccount) { LoginView(model: model) } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -34,13 +36,36 @@ struct SettingsView: View { } } #else - formcontent() + formcontent().sheet(isPresented: $showingAddAccount) { LoginView(model: model) } #endif } @ViewBuilder private func formcontent() -> some View { if let account = model.account { + Section("Аккаунты") { + ForEach(model.accounts) { configured in + Button { + Task { await model.switchAccount(to: configured) } + } label: { + HStack { + Image(systemName: configured.id == account.id ? "checkmark.circle.fill" : "person.circle") + .foregroundStyle(configured.id == account.id ? .green : .secondary) + VStack(alignment: .leading) { + Text(configured.displayName.isEmpty ? configured.normalizedJID : configured.displayName) + if !configured.displayName.isEmpty { Text(configured.normalizedJID).font(.caption).foregroundStyle(.secondary) } + } + Spacer() + } + } + .buttonStyle(.plain) + } + Button { + showingAddAccount = true + } label: { + Label("Добавить аккаунт", systemImage: "person.badge.plus") + } + } Section("Профиль") { HStack(spacing: 14) { AvatarView(