mv-experiment #1

Merged
fmodf merged 88 commits from mv-experiment into develop 2024-09-03 15:13:59 +00:00
7 changed files with 253 additions and 175 deletions
Showing only changes of commit a93b9d47c2 - Show all commits

View file

@ -42,11 +42,11 @@ final class Client: ObservableObject {
}
}
func addRoster(_ roster: Roster) async throws {
func addRoster(_ jid: String, name: String?, groups: [String]) async throws {
_ = try await connection.module(.roster).addItem(
jid: JID(roster.contactBareJid),
name: roster.name,
groups: roster.data.groups
jid: JID(jid),
name: name,
groups: groups
)
}

View file

@ -26,8 +26,7 @@ struct Credentials: DBStorable, Hashable {
}
}
// extension Account: UniversalInputSelectionElement {
// var text: String? { bareJid }
// var icon: Image? { nil }
// }
//
extension Credentials: UniversalInputSelectionElement {
var text: String? { bareJid }
var icon: Image? { nil }
}

View file

@ -62,10 +62,11 @@ extension Roster: Equatable {
}
extension Roster {
static func fetchAll(for jid: String) async throws -> [Roster] {
let rosters = try await Database.shared.dbQueue.read { db in
try Roster.filter(Column("bareJid") == jid).fetchAll(db)
mutating func setLocallyDeleted(_ value: Bool) async throws {
locallyDeleted = value
let copy = self
try? await Database.shared.dbQueue.write { db in
try copy.save(db)
}
return rosters
}
}

View file

@ -5,6 +5,7 @@ import GRDB
@MainActor
final class RostersStore: ObservableObject {
@Published private(set) var rosters: [Roster] = []
@Published private(set) var locallyDeletedRosters: [Roster] = []
private var cancellable: AnyCancellable?
@ -19,6 +20,7 @@ final class RostersStore: ObservableObject {
.catch { _ in Just([]) }
cancellable = clientsPublisher
.map { $0.filter { $0.state != .disabled } } // look rosters only for enabled clients
.flatMap { clients in
Publishers.MergeMany(clients.map { $0.$state })
.prepend(clients.map { $0.state })
@ -31,7 +33,80 @@ final class RostersStore: ObservableObject {
}
private func handleUpdates(clientStates: [ClientState], rosters: [Roster]) {
self.rosters = rosters
self.rosters = rosters.filter { !$0.locallyDeleted }
locallyDeletedRosters = rosters.filter { $0.locallyDeleted }
print("Client States: \(clientStates.count), Rosters: \(rosters.count)")
}
}
extension RostersStore {
enum RosterAddResult {
case success
case connectionError
case serverError
}
func addRoster(ownerJid: String, contactJID: String, name _: String?, groups _: [String]) async -> RosterAddResult {
// check if such roster was already exists or locally deleted
if var exists = rosters.first(where: { $0.bareJid == ownerJid && $0.contactBareJid == contactJID }), exists.locallyDeleted {
try? await exists.setLocallyDeleted(false)
return .success
}
// add new roster
// check that client is enabled and connected
guard let client = ClientsStore.shared.clients.first(where: { $0.credentials.bareJid == ownerJid }), client.state == .enabled(.connected) else {
return .connectionError
}
// add roster
do {
try await client.addRoster(contactJID, name: nil, groups: [])
return .success
} catch {
return .serverError
}
// guard let client = clientsStore.clients.first(where: { $0.credentials == ownerCredentials }), client.state == .enabled(.connected) else {
// router.showAlert(
// .alert,
// title: L10n.Global.Error.title,
// subtitle: L10n.Contacts.Add.connectionError
// ) {
// Button(L10n.Global.ok, role: .cancel) {}
// }
// return
// }
//
// router.showModal {
// LoadingScreen()
// }
//
// do {
// try await client.addRoster(contactJID, name: nil, groups: [])
// } catch {
// router.showAlert(
// .alert,
// title: L10n.Global.Error.title,
// subtitle: L10n.Contacts.Add.serverError
// ) {
// Button(L10n.Global.ok, role: .cancel) {}
// }
// return
// }
//
// router.dismissModal()
// router.dismissScreen()
// client.addRoster(jid: )
// guard let ownerAccount else { return }
// if let exists = store.state.rostersState.rosters.first(where: { $0.bareJid == ownerAccount.bareJid && $0.contactBareJid == contactJID }), exists.locallyDeleted {
// store.dispatch(.rostersAction(.unmarkRosterAsLocallyDeleted(ownerJID: ownerAccount.bareJid, contactJID: contactJID)))
// isPresented = false
// } else {
// isShowingLoader = true
// store.dispatch(.rostersAction(.addRoster(ownerJID: ownerAccount.bareJid, contactJID: contactJID, name: nil, groups: [])))
// }
}
}

View file

@ -24,8 +24,15 @@
"Tabs.Name.conversations" = "Chats";
"Tabs.Name.settings" = "Settings";
// MARK: Contacts screen
"Contacts.title" = "Contacts";
// MARK: Add contact/channel screen
"Contacts.Add.title" = "Add Contact";
"Contacts.Add.explanation" = "Contact or group/channel name are usually JID in format name@domain.ltd (like email)";
"Contacts.Add.serverError" = "Contact not added. Server returns error.";
"Contacts.Add.connectionError" = "You need to be connected to internet for add contact/channel";
@ -44,55 +51,51 @@
//"Login.Error.serverError" = "Server error. Check internet connection";
// MARK: Contacts screen
"Contacts.title" = "Contacts";
"Contacts.sendMessage" = "Send message";
"Contacts.editContact" = "Edit contact";
"Contacts.selectContact" = "Select contact";
"Contacts.deleteContact" = "Delete contact";
"Contacts.Add.title" = "Add Contact";
"Contacts.Add.explanation" = "Contact or group/channel name are usually JID in format name@domain.ltd (like email)";
"Contacts.Add.error" = "Contact not added. Server returns error.";
"Contacts.Delete.title" = "Delete contact";
"Contacts.Delete.message" = "You can delete contact from this device (contact will be available on other devices), or delete it completely";
"Contacts.Delete.deleteFromDevice" = "Delete from device";
"Contacts.Delete.deleteCompletely" = "Delete completely";
"Contacts.Delete.error" = "Contact not deleted. Server returns error.";
//"Contacts.sendMessage" = "Send message";
//"Contacts.editContact" = "Edit contact";
//"Contacts.selectContact" = "Select contact";
//"Contacts.deleteContact" = "Delete contact";
//"Contacts.Delete.title" = "Delete contact";
//"Contacts.Delete.message" = "You can delete contact from this device (contact will be available on other devices), or delete it completely";
//"Contacts.Delete.deleteFromDevice" = "Delete from device";
//"Contacts.Delete.deleteCompletely" = "Delete completely";
//"Contacts.Delete.error" = "Contact not deleted. Server returns error.";
// MARK: Chats screen
"Chats.title" = "Chats";
//"Chats.title" = "Chats";
"Chat.title" = "Chat";
"Chat.textfieldPrompt" = "Type a message";
//"Chat.title" = "Chat";
//"Chat.textfieldPrompt" = "Type a message";
"Chats.Create.Main.title" = "Create";
"Chats.Create.Main.createGroup" = "Create public group";
"Chats.Create.Main.createPrivateGroup" = "Create private group";
"Chats.Create.Main.findGroup" = "Find public group";
//"Chats.Create.Main.title" = "Create";
//"Chats.Create.Main.createGroup" = "Create public group";
//"Chats.Create.Main.createPrivateGroup" = "Create private group";
//"Chats.Create.Main.findGroup" = "Find public group";
// MARK: Accounts add screen
"Accounts.Add.or" = "or";
"Accounts.Add.Exist.title" = "Add existing\naccount";
"Accounts.Add.Exist.Prompt.jid" = "Enter your XMPP ID";
"Accounts.Add.Exist.Prompt.password" = "Enter password";
"Accounts.Add.Exist.Hint.jid" = "user@domain.im";
"Accounts.Add.Exist.Hint.password" = "password";
"Accounts.Add.Exist.Btn.link" = "create a new one";
"Accounts.Add.Exist.Btn.main" = "Continue";
"Accounts.Add.Exist.loginError" = "Wrong login or password";
//"Accounts.Add.or" = "or";
//"Accounts.Add.Exist.title" = "Add existing\naccount";
//"Accounts.Add.Exist.Prompt.jid" = "Enter your XMPP ID";
//"Accounts.Add.Exist.Prompt.password" = "Enter password";
//"Accounts.Add.Exist.Hint.jid" = "user@domain.im";
//"Accounts.Add.Exist.Hint.password" = "password";
//"Accounts.Add.Exist.Btn.link" = "create a new one";
//"Accounts.Add.Exist.Btn.main" = "Continue";
//"Accounts.Add.Exist.loginError" = "Wrong login or password";
// MARK: Server connecting indicator
"ServerConnectingIndicator.State.connecting" = "Connecting to server";
"ServerConnectingIndicator.State.connected" = "Connected";
"ServerConnectingIndicator.State.error" = "Server unreachable. Check internet connection and server name";
//"ServerConnectingIndicator.State.connecting" = "Connecting to server";
//"ServerConnectingIndicator.State.connected" = "Connected";
//"ServerConnectingIndicator.State.error" = "Server unreachable. Check internet connection and server name";
// MARK: Attachments
"Attachment.Prompt.main" = "Select attachment";
"Attachment.Tab.media" = "Media";
"Attachment.Tab.files" = "Files";
"Attachment.Tab.location" = "Location";
"Attachment.Tab.contacts" = "Contacts";
"Attachment.Send.media" = "Send media";
"Attachment.Send.location" = "Send location";
"Attachment.Send.contact" = "Send contact";
"Attachment.Downloading.retry" = "Retry";
//"Attachment.Prompt.main" = "Select attachment";
//"Attachment.Tab.media" = "Media";
//"Attachment.Tab.files" = "Files";
//"Attachment.Tab.location" = "Location";
//"Attachment.Tab.contacts" = "Contacts";
//"Attachment.Send.media" = "Send media";
//"Attachment.Send.location" = "Send location";
//"Attachment.Send.contact" = "Send contact";
//"Attachment.Downloading.retry" = "Retry";

View file

@ -2,21 +2,18 @@ import SwiftUI
struct AddContactOrChannelScreen: View {
@Environment(\.router) var router
@EnvironmentObject var clientsStore: ClientsStore
@EnvironmentObject var rostersStore: RostersStore
// enum Field {
// case account
// case contact
// }
//
// @FocusState private var focus: Field?
//
// @Binding var isPresented: Bool
// @State private var contactJID: String = ""
// // @State private var ownerAccount: Account?
//
// @State private var isShowingLoader = false
// @State private var isShowingAlert = false
// @State private var errorMsg = ""
enum Field {
case account
case contact
}
@FocusState private var focus: Field?
@State private var ownerCredentials: Credentials?
@State private var contactJID: String = ""
var body: some View {
ZStack {
@ -43,107 +40,117 @@ struct AddContactOrChannelScreen: View {
)
)
// VStack(spacing: 16) {
// // Explanation text
//
// Text(L10n.Contacts.Add.explanation)
// .font(.body3)
// .foregroundColor(.Material.Shape.separator)
// .multilineTextAlignment(.center)
// .padding(.top, 16)
//
// // Account selector
// HStack(spacing: 0) {
// Text("Use account:")
// .font(.body2)
// .foregroundColor(.Material.Text.main)
// .frame(alignment: .leading)
// Spacer()
// }
// // UniversalInputCollection.DropDownMenu(
// // prompt: "Use account",
// // elements: store.state.accountsState.accounts,
// // selected: $ownerAccount,
// // focus: $focus,
// // fieldType: .account
// // )
//
// // Contact text input
// HStack(spacing: 0) {
// Text("Contact JID:")
// .font(.body2)
// .foregroundColor(.Material.Text.main)
// .frame(alignment: .leading)
// Spacer()
// }
// UniversalInputCollection.TextField(
// prompt: "Contact or channel JID",
// text: $contactJID,
// focus: $focus,
// fieldType: .contact,
// contentType: .emailAddress,
// keyboardType: .emailAddress,
// submitLabel: .done,
// action: {
// focus = .account
// }
// )
//
// // Save button
// Button {
// navigation.flow = .main(.contacts(.list))
// } label: {
// Text(L10n.Global.save)
// }
// .buttonStyle(PrimaryButtonStyle())
// .disabled(!inputValid)
// .padding(.top)
// Spacer()
// }
// Content
VStack(spacing: 16) {
// Explanation text
Text(L10n.Contacts.Add.explanation)
.font(.body3)
.foregroundColor(.Material.Shape.separator)
.multilineTextAlignment(.center)
.padding(.top, 16)
// Account selector
HStack(spacing: 0) {
Text("Use account:")
.font(.body2)
.foregroundColor(.Material.Text.main)
.frame(alignment: .leading)
Spacer()
}
UniversalInputCollection.DropDownMenu(
prompt: "Use account",
elements: activeClientsCredentials,
selected: $ownerCredentials,
focus: $focus,
fieldType: .account
)
// Contact text input
HStack(spacing: 0) {
Text("Contact JID:")
.font(.body2)
.foregroundColor(.Material.Text.main)
.frame(alignment: .leading)
Spacer()
}
UniversalInputCollection.TextField(
prompt: "Contact or channel JID",
text: $contactJID,
focus: $focus,
fieldType: .contact,
contentType: .emailAddress,
keyboardType: .emailAddress,
submitLabel: .done,
action: {
focus = .account
}
)
// Save button
Button {
Task {
await save()
}
} label: {
Text(L10n.Global.save)
}
.buttonStyle(PrimaryButtonStyle())
.disabled(!inputValid)
.padding(.top)
Spacer()
}
.padding(.horizontal, 32)
}
}
// .onAppear {
// if let exists = store.state.accountsState.accounts.first, exists.isActive {
// ownerAccount = exists
// }
// }
// .loadingIndicator(isShowingLoader)
// .alert(isPresented: $isShowingAlert) {
// Alert(
// title: Text(L10n.Global.Error.title),
// message: Text(errorMsg),
// dismissButton: .default(Text(L10n.Global.ok))
// )
// }
// .onChange(of: store.state.rostersState.newAddedRosterJid) { jid in
// if jid != nil, isShowingLoader {
// isShowingLoader = false
// isPresented = false
// }
// }
// .onChange(of: store.state.rostersState.newAddedRosterError) { error in
// if let error = error, isShowingLoader {
// isShowingLoader = false
// errorMsg = error
// isShowingAlert = true
// }
// }
.onAppear {
if let exists = activeClientsCredentials.first {
ownerCredentials = exists
}
}
}
private var activeClientsCredentials: [Credentials] {
clientsStore.clients
.filter { $0.state != .disabled }
.map { $0.credentials }
}
private var inputValid: Bool {
true
// ownerAccount != nil && !contactJID.isEmpty && UniversalInputCollection.Validators.isEmail(contactJID)
ownerCredentials != nil && !contactJID.isEmpty && UniversalInputCollection.Validators.isEmail(contactJID)
}
// private func save() {
// guard let ownerAccount else { return }
// if let exists = store.state.rostersState.rosters.first(where: { $0.bareJid == ownerAccount.bareJid && $0.contactBareJid == contactJID }), exists.locallyDeleted {
// store.dispatch(.rostersAction(.unmarkRosterAsLocallyDeleted(ownerJID: ownerAccount.bareJid, contactJID: contactJID)))
// isPresented = false
// } else {
// isShowingLoader = true
// store.dispatch(.rostersAction(.addRoster(ownerJID: ownerAccount.bareJid, contactJID: contactJID, name: nil, groups: [])))
// }
// }
private func save() async {
guard let ownerCredentials = ownerCredentials else { return }
router.showModal {
LoadingScreen()
}
defer {
router.dismissModal()
}
let result = await rostersStore.addRoster(ownerJid: ownerCredentials.bareJid, contactJID: contactJID, name: nil, groups: [])
switch result {
case .success:
router.dismissScreen()
case .connectionError:
showErrorAlert(subtitle: L10n.Contacts.Add.connectionError)
case .serverError:
showErrorAlert(subtitle: L10n.Contacts.Add.serverError)
}
}
private func showErrorAlert(subtitle: String) {
router.showAlert(
.alert,
title: L10n.Global.Error.title,
subtitle: subtitle
) {
Button(L10n.Global.ok, role: .cancel) {}
}
}
}

View file

@ -5,11 +5,6 @@ struct ContactsScreen: View {
@EnvironmentObject var clientsStore: ClientsStore
@StateObject var rostersStore = RostersStore(clientsPublisher: ClientsStore.shared.$clients)
// @State private var addPanelPresented = false
// @State private var isErrorAlertPresented = false
// @State private var errorAlertMessage = ""
// @State private var isShowingLoader = false
var body: some View {
ZStack {
// Background color
@ -26,6 +21,7 @@ struct ContactsScreen: View {
action: {
router.showScreen(.fullScreenCover) { _ in
AddContactOrChannelScreen()
.environmentObject(rostersStore)
}
}
)
@ -37,9 +33,6 @@ struct ContactsScreen: View {
ForEach(rostersStore.rosters) { roster in
ContactsScreenRow(
roster: roster
// isErrorAlertPresented: $isErrorAlertPresented,
// errorAlertMessage: $errorAlertMessage,
// isShowingLoader: $isShowingLoader
)
}
}
@ -103,9 +96,9 @@ private struct ContactsScreenRow: View {
iconType: .charCircle(roster.name?.firstLetter ?? roster.contactBareJid.firstLetter),
text: roster.contactBareJid
)
// .onTapGesture {
// store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid)))
// }
.onTapGesture {
// store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid)))
}
// .onLongPressGesture {
// isShowingMenu.toggle()
// }