added multi accounting #4
This commit is contained in:
1 parent
95a1c62b54
commit
ca1a69e61f
5 files changed
+211
-34
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user