switch to async/await

This commit is contained in:
fmodf 2024-08-11 02:28:01 +02:00
parent 44ef6c25ba
commit 23f31c4055
248 changed files with 10811 additions and 63 deletions

View file

@ -0,0 +1,116 @@
import Combine
import Foundation
import Martin
enum ClientState: Equatable {
enum ClientConnectionState {
case connected
case disconnected
}
case disabled
case enabled(ClientConnectionState)
}
final class Client: ObservableObject {
@Published private(set) var state: ClientState = .enabled(.disconnected)
@Published private(set) var credentials: Credentials
private var connection: XMPPClient
private var connectionCancellable: AnyCancellable?
private var rosterManager = RosterManager()
init(credentials: Credentials) {
self.credentials = credentials
state = credentials.isActive ? .enabled(.disconnected) : .disabled
connection = Self.prepareConnection(credentials, rosterManager)
connectionCancellable = connection.$state
.sink { [weak self] state in
guard let self = self else { return }
guard self.credentials.isActive else {
self.state = .disabled
return
}
switch state {
case .connected:
self.state = .enabled(.connected)
default:
self.state = .enabled(.disconnected)
}
}
}
func addRoster(_ roster: Roster) async throws {
_ = try await connection.module(.roster).addItem(
jid: JID(roster.contactBareJid),
name: roster.name,
groups: roster.data.groups
)
}
func deleteRoster(_ roster: Roster) async throws {
_ = try await connection.module(.roster).removeItem(jid: JID(roster.contactBareJid))
}
}
extension Client {
enum ClientLoginResult {
case success(Client)
case failure
}
static func tryLogin(with credentials: Credentials) async -> ClientLoginResult {
let client = Client(credentials: credentials)
do {
try await client.connection.loginAndWait()
return .success(client)
} catch {
return .failure
}
}
}
private extension Client {
static func prepareConnection(_ credentials: Credentials, _ roster: RosterManager) -> XMPPClient {
let client = XMPPClient()
// register modules
// core modules RFC 6120
client.modulesManager.register(StreamFeaturesModule())
client.modulesManager.register(SaslModule())
client.modulesManager.register(AuthModule())
client.modulesManager.register(SessionEstablishmentModule())
client.modulesManager.register(ResourceBinderModule())
client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName)))
// messaging modules RFC 6121
client.modulesManager.register(RosterModule(rosterManager: roster))
client.modulesManager.register(PresenceModule())
// client.modulesManager.register(PubSubModule())
// client.modulesManager.register(MessageModule(chatManager: manager))
// client.modulesManager.register(MessageArchiveManagementModule())
// client.modulesManager.register(MessageCarbonsModule())
// file transfer modules
// client.modulesManager.register(HttpFileUploadModule())
// extensions
client.modulesManager.register(SoftwareVersionModule())
client.modulesManager.register(PingModule())
client.connectionConfiguration.userJid = .init(credentials.bareJid)
client.connectionConfiguration.credentials = .password(password: credentials.pass)
// group chats
// client.modulesManager.register(MucModule(roomManager: manager))
// channels
// client.modulesManager.register(MixModule(channelManager: manager))
// add client to clients
return client
}
}

View file

@ -0,0 +1,152 @@
import Foundation
import GRDB
import Martin
final class RosterManager: Martin.RosterManager {
func clear(for context: Martin.Context) {
do {
try Database.shared.dbQueue.write { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.deleteAll(db)
try RosterVersion
.filter(Column("bareJid") == context.userBareJid.stringValue)
.deleteAll(db)
}
} catch {
logIt(.error, "Error clearing roster: \(error.localizedDescription)")
}
}
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
do {
let rosters: [Roster] = try Database.shared.dbQueue.read { db in
try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db)
}
return rosters.map { roster in
RosterItemBase(
jid: JID(roster.bareJid),
name: roster.name,
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
groups: roster.data.groups,
ask: roster.ask,
annotations: roster.data.annotations
)
}
} catch {
logIt(.error, "Error fetching roster items: \(error.localizedDescription)")
return []
}
}
func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
do {
let roster: Roster? = try Database.shared.dbQueue.read { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.filter(Column("contactBareJid") == jid.stringValue)
.fetchOne(db)
}
if let roster {
return RosterItemBase(
jid: JID(roster.bareJid),
name: roster.name,
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
groups: roster.data.groups,
ask: roster.ask,
annotations: roster.data.annotations
)
} else {
return nil
}
} catch {
logIt(.error, "Error fetching roster item: \(error.localizedDescription)")
return nil
}
}
func updateItem(for context: Martin.Context, jid: Martin.JID, name: String?, subscription: Martin.RosterItemSubscription, groups: [String], ask: Bool, annotations: [Martin.RosterItemAnnotation]) -> (any Martin.RosterItemProtocol)? {
do {
let roster = Roster(
bareJid: context.userBareJid.stringValue,
contactBareJid: jid.stringValue,
name: name,
subscription: subscription.rawValue,
ask: ask,
data: DBRosterData(
groups: groups,
annotations: annotations
)
)
try Database.shared.dbQueue.write { db in
try roster.save(db)
}
return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations)
} catch {
logIt(.error, "Error updating roster item: \(error.localizedDescription)")
return nil
}
}
func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
do {
let roster: Roster? = try Database.shared.dbQueue.read { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.filter(Column("contactBareJid") == jid.stringValue)
.fetchOne(db)
}
if let roster {
_ = try Database.shared.dbQueue.write { db in
try roster.delete(db)
}
return RosterItemBase(
jid: JID(roster.bareJid),
name: roster.name,
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
groups: roster.data.groups,
ask: roster.ask,
annotations: roster.data.annotations
)
} else {
return nil
}
} catch {
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
return nil
}
}
func version(for context: Martin.Context) -> String? {
do {
let version: RosterVersion? = try Database.shared.dbQueue.read { db in
try RosterVersion
.filter(Column("bareJid") == context.userBareJid.stringValue)
.fetchOne(db)
}
return version?.version
} catch {
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
return nil
}
}
func set(version: String?, for context: Martin.Context) {
guard let version else { return }
do {
try Database.shared.dbQueue.write { db in
let rosterVersion = RosterVersion(
bareJid: context.userBareJid.stringValue,
version: version
)
try rosterVersion.save(db)
}
} catch {
logIt(.error, "Error setting roster version: \(error.localizedDescription)")
}
}
func initialize(context _: Martin.Context) {}
func deinitialize(context _: Martin.Context) {}
}

View file

@ -0,0 +1,33 @@
import Combine
import Foundation
import GRDB
import SwiftUI
struct Credentials: DBStorable, Hashable {
static let databaseTableName = "credentials"
var id: String { bareJid }
var bareJid: String
var pass: String
var isActive: Bool
func save() async throws {
let db = Database.shared.dbQueue
try await db.write { db in
try self.save(db)
}
}
func delete() async throws {
let db = Database.shared.dbQueue
_ = try await db.write { db in
try self.delete(db)
}
}
}
// extension Account: UniversalInputSelectionElement {
// var text: String? { bareJid }
// var icon: Image? { nil }
// }
//

View file

@ -0,0 +1,71 @@
import Foundation
import GRDB
import Martin
struct RosterVersion: DBStorable {
static let databaseTableName = "rosterVersions"
var bareJid: String
var version: String
var id: String { bareJid }
}
struct Roster: DBStorable {
static let databaseTableName = "rosters"
var bareJid: String = ""
var contactBareJid: String
var name: String?
var subscription: String
var ask: Bool
var data: DBRosterData
var locallyDeleted: Bool = false
var id: String { "\(bareJid)-\(contactBareJid)" }
}
struct DBRosterData: Codable, DatabaseValueConvertible {
let groups: [String]
let annotations: [RosterItemAnnotation]
public var databaseValue: DatabaseValue {
let encoder = JSONEncoder()
// swiftlint:disable:next force_try
let data = try! encoder.encode(self)
return data.databaseValue
}
public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? {
guard let data = Data.fromDatabaseValue(dbValue) else {
return nil
}
let decoder = JSONDecoder()
// swiftlint:disable:next force_try
return try! decoder.decode(Self.self, from: data)
}
static func == (lhs: DBRosterData, rhs: DBRosterData) -> Bool {
lhs.groups == rhs.groups && lhs.annotations == rhs.annotations
}
}
extension RosterItemAnnotation: Equatable {
public static func == (lhs: RosterItemAnnotation, rhs: RosterItemAnnotation) -> Bool {
lhs.type == rhs.type && lhs.values == rhs.values
}
}
extension Roster: Equatable {
static func == (lhs: Roster, rhs: Roster) -> Bool {
lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid
}
}
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)
}
return rosters
}
}

View file

@ -0,0 +1,42 @@
import Foundation
import GRDB
extension Database {
static var migrator: DatabaseMigrator = {
var migrator = DatabaseMigrator()
// flush db on schema change (only in DEV mode)
#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
#endif
// 1st migration - basic tables
migrator.registerMigration("Add basic tables") { db in
// credentials
try db.create(table: "credentials", options: [.ifNotExists]) { table in
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
table.column("pass", .text).notNull()
table.column("isActive", .boolean).notNull().defaults(to: true)
}
// rosters
try db.create(table: "rosterVersions", options: [.ifNotExists]) { table in
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
table.column("version", .text).notNull()
}
try db.create(table: "rosters", options: [.ifNotExists]) { table in
table.column("bareJid", .text).notNull()
table.column("contactBareJid", .text).notNull()
table.column("name", .text)
table.column("subscription", .text).notNull()
table.column("ask", .boolean).notNull().defaults(to: false)
table.column("data", .text).notNull()
table.primaryKey(["bareJid", "contactBareJid"], onConflict: .replace)
table.column("locallyDeleted", .boolean).notNull().defaults(to: false)
}
}
// return migrator
return migrator
}()
}

View file

@ -0,0 +1,55 @@
import Combine
import Foundation
import GRDB
import SwiftUI
// MARK: - Models protocol
typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRecord & TableRecord
// MARK: - Database init
final class Database {
static let shared = Database()
let dbQueue: DatabaseQueue
private init() {
do {
// Create db folder if not exists
let fileManager = FileManager.default
let appSupportURL = try fileManager.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true
)
let directoryURL = appSupportURL.appendingPathComponent("ConversationsClassic", isDirectory: true)
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
// Open or create the database
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
dbQueue = try DatabaseQueue(path: databaseURL.path, configuration: Database.config)
// Some debug info
#if DEBUG
print("Database path: \(databaseURL.path)")
#endif
// Apply migrations
try Database.migrator.migrate(dbQueue)
} catch {
fatalError("Database initialization failed: \(error)")
}
}
}
// MARK: - Config
private extension Database {
static let config: Configuration = {
var config = Configuration()
#if DEBUG
// verbose and debugging in DEBUG builds only.
config.publicStatementArguments = true
config.prepareDatabase { db in
db.trace { print("SQL> \($0)\n") }
}
#endif
return config
}()
}

View file

@ -0,0 +1,42 @@
import Combine
import Foundation
import SwiftUI
let isConsoleLoggingEnabled = false
enum LogLevels: String {
case info = "\u{F449}"
case warning = "\u{F071}"
case error = "\u{EA76}"
}
// For database errors logging
func logIt(_ level: LogLevels, _ message: String) {
#if DEBUG
let timeStr = dateFormatter.string(from: Date())
let str = "\(timeStr) \(level.rawValue) \(message)"
print(str)
if isConsoleLoggingEnabled {
NSLog(str)
}
#endif
}
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale
formatter.dateFormat = "MM-dd HH:mm:ss.SSS"
return formatter
}
// For thread debugging
func ptInfo(_ message: String) {
#if DEBUG
let timeStr = dateFormatter.string(from: Date())
let str = "\(timeStr) \(message) -> \(Thread.current), \(String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "no queue label")"
print(str)
if isConsoleLoggingEnabled {
NSLog(str)
}
#endif
}

View file

@ -0,0 +1,37 @@
import Combine
import Network
extension NWPathMonitor {
func paths() -> AsyncStream<NWPath> {
AsyncStream { continuation in
pathUpdateHandler = { path in
continuation.yield(path)
}
continuation.onTermination = { [weak self] _ in
self?.cancel()
}
start(queue: DispatchQueue(label: "NSPathMonitor.paths"))
}
}
}
final actor NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
@Published private(set) var isOnline: Bool = false
private let monitor = NWPathMonitor()
init() {
Task(priority: .background) {
await startMonitoring()
}
}
func startMonitoring() async {
let monitor = NWPathMonitor()
for await path in monitor.paths() {
isOnline = path.status == .satisfied
}
}
}

View file

@ -0,0 +1,41 @@
import Combine
import Foundation
import GRDB
@MainActor
final class ClientsStore: ObservableObject {
static let shared = ClientsStore()
@Published private(set) var ready = false
@Published private(set) var clients: [Client] = []
func startFetching() {
Task {
let observation = ValueObservation.tracking(Credentials.fetchAll)
do {
for try await credentials in observation.values(in: Database.shared.dbQueue) {
processCredentials(credentials)
ready = true
print("Fetched \(credentials.count) credentials")
}
} catch {}
}
}
func addNewClient(_ client: Client) {
clients.append(client)
Task(priority: .background) {
try? await client.credentials.save()
}
}
private func processCredentials(_ credentials: [Credentials]) {
let existsJids = clients.map { $0.credentials.bareJid }
let forAdd = credentials.filter { !existsJids.contains($0.bareJid) }
let forRemove = existsJids.filter { !credentials.map { $0.bareJid }.contains($0) }
var newClients = clients.filter { !forRemove.contains($0.credentials.bareJid) }
newClients.append(contentsOf: forAdd.map { Client(credentials: $0) })
clients = newClients
}
}

View file

@ -0,0 +1,25 @@
import Combine
import Foundation
@MainActor
final class NavigationStore: ObservableObject {
enum Flow: Equatable {
enum Entering {
case welcome
case login
case registration
}
enum Main {
case contacts
case conversations
case settings
}
case start
case entering(Entering)
case main(Main)
}
@Published var flow: Flow = .start
}

View file

@ -0,0 +1,21 @@
import Combine
import Foundation
import GRDB
@MainActor
final class RostersStore: ObservableObject {
@Published private(set) var rosters: [Roster] = []
init() {
// Task {
// let observation = ValueObservation.tracking(Roster.fetchAll)
// do {
// for try await credentials in observation.values(in: Database.shared.dbQueue) {
// processCredentials(credentials)
// ready = true
// print("Fetched \(credentials.count) credentials")
// }
// } catch {}
// }
}
}

View file

@ -1,31 +1,17 @@
import Combine import Combine
import SwiftUI import SwiftUI
let appState = AppState()
let store = AppStore(
initialState: appState,
reducer: AppState.reducer,
middlewares: [
loggerMiddleware(),
StartMiddleware.shared.middleware,
DatabaseMiddleware.shared.middleware,
AccountsMiddleware.shared.middleware,
XMPPMiddleware.shared.middleware,
RostersMiddleware.shared.middleware,
ChatsMiddleware.shared.middleware,
ArchivedMessagesMiddleware.shared.middleware,
ConversationMiddleware.shared.middleware,
SharingMiddleware.shared.middleware,
FileMiddleware.shared.middleware
]
)
@main @main
@MainActor
struct ConversationsClassic: App { struct ConversationsClassic: App {
private var clientsStore = ClientsStore()
private var navigationStore = NavigationStore()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
BaseNavigationView() AppRootView()
.environmentObject(store) .environmentObject(navigationStore)
.environmentObject(clientsStore)
} }
} }
} }

View file

@ -36,28 +36,28 @@ extension String {
} }
} }
extension String { // extension String {
var attachmentType: MessageAttachmentType { // var attachmentType: MessageAttachmentType {
let ext = (self as NSString).pathExtension.lowercased() // let ext = (self as NSString).pathExtension.lowercased()
//
switch ext { // switch ext {
case "mov", "mp4", "avi": // case "mov", "mp4", "avi":
return .movie // return .movie
//
case "jpg", "png", "gif": // case "jpg", "png", "gif":
return .image // return .image
//
case "mp3", "wav", "m4a": // case "mp3", "wav", "m4a":
return .audio // return .audio
//
case "txt", "doc", "pdf": // case "txt", "doc", "pdf":
return .file // return .file
//
default: // default:
return .file // return .file
} // }
} // }
} // }
extension String { extension String {
var firstLetterColor: Color { var firstLetterColor: Color {

View file

@ -5,21 +5,39 @@
"Global.cancel" = "Cancel"; "Global.cancel" = "Cancel";
"Global.save" = "Save"; "Global.save" = "Save";
"Global.Error.title" = "Error"; "Global.Error.title" = "Error";
"Global.Error.genericText" = "Something went wrong";
"Global.Error.genericDbError" = "Database error";
// MARK: Onboar screen // MARK: Welcome screen
"Start.subtitle" = "Free and secure messaging and calls between any existed messengers"; "Start.subtitle" = "Free and secure messaging and calls between any existed messengers";
"Start.Btn.login" = "Enter with JID"; "Start.Btn.login" = "Enter with JID";
"Start.Btn.register" = "New Account"; "Start.Btn.register" = "New Account";
// MARK: Login
"Login.title" = "Let\'s go!"; "Login.title" = "Let\'s go!";
"Login.subtitle" = "Enter your JID, it should looks like email address"; "Login.subtitle" = "Enter your JID, it should looks like email address";
"Login.Hint.jid" = "user@domain.im"; "Login.Hint.jid" = "user@domain.im";
"Login.Hint.password" = "password"; "Login.Hint.password" = "password";
"Login.btn" = "Continue"; "Login.btn" = "Continue";
"Login.Error.wrongPassword" = "Wrong password or JID"; "Login.error" = "Check internet connection, and make sure that JID and password are correct";
"Login.Error.noServer" = "Server not exists";
"Login.Error.serverError" = "Server error. Check internet connection";
// MARK: Onboar screen
//"Login.title" = "Let\'s go!";
//"Login.subtitle" = "Enter your JID, it should looks like email address";
//"Login.Hint.jid" = "user@domain.im";
//"Login.Hint.password" = "password";
//"Login.btn" = "Continue";
//"Login.Error.wrongPassword" = "Wrong password or JID";
//"Login.Error.noServer" = "Server not exists";
//"Login.Error.serverError" = "Server error. Check internet connection";
// MARK: Contacts screen // MARK: Contacts screen
"Contacts.title" = "Contacts"; "Contacts.title" = "Contacts";

View file

@ -0,0 +1,36 @@
import Foundation
import SwiftUI
public extension View {
func loadingOverlay() -> some View {
modifier(LoadingOverlay())
}
}
struct LoadingOverlay: ViewModifier {
func body(content: Content) -> some View {
ZStack {
content
loadingView
}
}
private var loadingView: some View {
GeometryReader { proxyReader in
ZStack {
Color.Material.Elements.active.opacity(0.3)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// loader
ProgressView()
.progressViewStyle(
CircularProgressViewStyle(tint: .Material.Elements.active)
)
.position(x: proxyReader.size.width / 2, y: proxyReader.size.height / 2)
.controlSize(.large)
}
}
.ignoresSafeArea()
.transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.1)))
}
}

View file

@ -0,0 +1,38 @@
import SwiftUI
struct AppRootView: View {
@EnvironmentObject var navigation: NavigationStore
var body: some View {
Group {
switch navigation.flow {
case .start:
StartScreen()
case .entering(let entering):
switch entering {
case .welcome:
WelcomeScreen()
case .login:
LoginScreen()
case .registration:
RegistrationScreen()
}
case .main(let main):
switch main {
case .contacts:
ContactsScreen()
case .conversations:
EmptyView()
case .settings:
EmptyView()
}
}
}
}
}

View file

@ -0,0 +1,172 @@
import SwiftUI
struct ContactsScreen: View {
@EnvironmentObject var clientsStore: ClientsStore
// @State private var addPanelPresented = false
// @State private var isErrorAlertPresented = false
// @State private var errorAlertMessage = ""
// @State private var isShowingLoader = false
@State private var rosters: [Roster] = []
var body: some View {
ZStack {
// Background color
Color.Material.Background.light
.ignoresSafeArea()
// Content
VStack(spacing: 0) {
// Header
SharedNavigationBar(
centerText: .init(text: L10n.Contacts.title),
rightButton: .init(
image: Image(systemName: "plus"),
action: {
// addPanelPresented = true
}
)
)
// Contacts list
if !rosters.isEmpty {
List {
ForEach(rosters) { roster in
ContactsScreenRow(
roster: roster
// isErrorAlertPresented: $isErrorAlertPresented,
// errorAlertMessage: $errorAlertMessage,
// isShowingLoader: $isShowingLoader
)
}
}
.listStyle(.plain)
.background(Color.Material.Background.light)
} else {
Spacer()
}
// Tab bar
SharedTabBar()
}
}
.task {
await fetchRosters()
}
// .loadingIndicator(isShowingLoader)
// .fullScreenCover(isPresented: $addPanelPresented) {
// AddContactOrChannelScreen(isPresented: $addPanelPresented)
// }
// .alert(isPresented: $isErrorAlertPresented) {
// Alert(
// title: Text(L10n.Global.Error.title),
// message: Text(errorAlertMessage),
// dismissButton: .default(Text(L10n.Global.ok))
// )
// }
}
private func fetchRosters() async {
let jids = clientsStore.clients
.filter { $0.state != .disabled }
.map { $0.credentials.bareJid }
do {
try await withThrowingTaskGroup(of: [Roster].self) { group in
for jid in jids {
group.addTask {
try await Roster.fetchAll(for: jid)
}
}
var allRosters: [Roster] = []
for try await rosters in group {
allRosters.append(contentsOf: rosters)
}
self.rosters = allRosters.sorted { $0.contactBareJid < $1.contactBareJid }
}
} catch {}
}
}
private struct ContactsScreenRow: View {
var roster: Roster
// @State private var isShowingMenu = false
// @State private var isDeleteAlertPresented = false
//
// @Binding var isErrorAlertPresented: Bool
// @Binding var errorAlertMessage: String
// @Binding var isShowingLoader: Bool
var body: some View {
SharedListRow(
iconType: .charCircle(roster.name?.firstLetter ?? roster.contactBareJid.firstLetter),
text: roster.contactBareJid
)
// .onTapGesture {
// store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid)))
// }
// .onLongPressGesture {
// isShowingMenu.toggle()
// }
// .swipeActions(edge: .trailing, allowsFullSwipe: false) {
// Button {
// isDeleteAlertPresented = true
// } label: {
// Label(L10n.Contacts.sendMessage, systemImage: "trash")
// }
// .tint(Color.red)
// }
// .contextMenu {
// Button(L10n.Contacts.sendMessage, systemImage: "message") {
// store.dispatch(.chatsAction(.startChat(accountJid: roster.bareJid, participantJid: roster.contactBareJid)))
// }
// Divider()
//
// Button(L10n.Contacts.editContact) {
// print("Edit contact")
// }
//
// Button(L10n.Contacts.selectContact) {
// print("Select contact")
// }
//
// Divider()
// Button(L10n.Contacts.deleteContact, systemImage: "trash", role: .destructive) {
// isDeleteAlertPresented = true
// }
// }
// .actionSheet(isPresented: $isDeleteAlertPresented) {
// ActionSheet(
// title: Text(L10n.Contacts.Delete.title),
// message: Text(L10n.Contacts.Delete.message),
// buttons: [
// .destructive(Text(L10n.Contacts.Delete.deleteFromDevice)) {
// store.dispatch(.rostersAction(.markRosterAsLocallyDeleted(ownerJID: roster.bareJid, contactJID: roster.contactBareJid)))
// },
// .destructive(Text(L10n.Contacts.Delete.deleteCompletely)) {
// isShowingLoader = true
// store.dispatch(.rostersAction(.deleteRoster(ownerJID: roster.bareJid, contactJID: roster.contactBareJid)))
// },
// .cancel(Text(L10n.Global.cancel))
// ]
// )
// }
// .onChange(of: store.state.rostersState.rosters) { _ in
// endOfDeleting()
// }
// .onChange(of: store.state.rostersState.deleteRosterError) { _ in
// endOfDeleting()
// }
}
// private func endOfDeleting() {
// if isShowingLoader {
// isShowingLoader = false
// if let error = store.state.rostersState.deleteRosterError {
// errorAlertMessage = error
// isErrorAlertPresented = true
// }
// }
// }
}

View file

@ -0,0 +1,141 @@
import Combine
import Martin
import SwiftUI
struct LoginScreen: View {
@EnvironmentObject var navigation: NavigationStore
@EnvironmentObject var clientsStore: ClientsStore
enum Field {
case userJid
case password
}
@FocusState private var focus: Field?
@State private var isLoading = false
@State private var isError = false
#if DEBUG
@State private var jidStr: String = "nartest1@conversations.im"
@State private var pass: String = "nartest12345"
// @State private var jidStr: String = "test1@test.anal.company"
// @State private var pass: String = "12345"
#else
@State private var jidStr: String = ""
@State private var pass: String = ""
#endif
public var body: some View {
ZStack {
// background
Color.Material.Background.light
.ignoresSafeArea()
// content
VStack(spacing: 32) {
// icon
Image.logo
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 120, height: 120)
// texts
VStack(spacing: 10) {
Text(L10n.Login.title)
.font(.head1l)
.foregroundColor(.Material.Text.main)
.fixedSize(horizontal: true, vertical: false)
Text(L10n.Login.subtitle)
.font(.body2)
.foregroundColor(.Material.Text.sub)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
VStack(spacing: 16) {
UniversalInputCollection.TextField(
prompt: L10n.Login.Hint.jid,
text: $jidStr,
focus: $focus,
fieldType: .userJid,
contentType: .emailAddress,
keyboardType: .emailAddress,
submitLabel: .next,
action: {
focus = .password
}
)
UniversalInputCollection.SecureField(
prompt: L10n.Login.Hint.password,
text: $pass,
focus: $focus,
fieldType: .password,
submitLabel: .go,
action: {
focus = nil
}
)
Button {
Task {
await tryLogin()
}
} label: {
Text(L10n.Login.btn)
}
.buttonStyle(PrimaryButtonStyle())
.disabled(!loginInputValid)
Button {
withAnimation {
navigation.flow = .entering(.welcome)
}
} label: {
Text("\(Image(systemName: "chevron.left")) \(L10n.Global.back)")
.foregroundColor(.Material.Elements.active)
.font(.body2)
}
}
}
.padding(.horizontal, 32)
}
.if(isLoading) {
$0.loadingOverlay()
}
.alert(isPresented: $isError) {
Alert(
title: Text(L10n.Global.Error.title),
message: Text(L10n.Login.error),
dismissButton: .default(Text(L10n.Global.ok))
)
}
}
private var loginInputValid: Bool {
!jidStr.isEmpty && !pass.isEmpty && UniversalInputCollection.Validators.isEmail(jidStr)
}
private func tryLogin() async {
isLoading = true
// login with fake timeout
async let sleep: Void? = try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
async let request = await Client.tryLogin(with: .init(bareJid: jidStr, pass: pass, isActive: true))
let result = await(request, sleep).0
switch result {
case .success(let client):
clientsStore.addNewClient(client)
isLoading = false
isError = false
if navigation.flow == .entering(.login) {
navigation.flow = .main(.contacts)
}
case .failure:
isLoading = false
isError = true
}
}
}

View file

@ -0,0 +1,22 @@
import SwiftUI
struct RegistrationScreen: View {
@EnvironmentObject var navigation: NavigationStore
public var body: some View {
ZStack {
Color.Material.Background.light
Button {
withAnimation {
navigation.flow = .entering(.welcome)
}
} label: {
VStack {
Text("Not yet implemented")
Text(L10n.Global.back)
}
}
}
.ignoresSafeArea()
}
}

View file

@ -0,0 +1,56 @@
import SwiftUI
struct WelcomeScreen: View {
@EnvironmentObject var navigation: NavigationStore
public var body: some View {
ZStack {
// background
Color.Material.Background.light
.ignoresSafeArea()
// content
VStack(spacing: 32) {
// icon
Image.logo
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 120, height: 120)
// texts
VStack(spacing: 10) {
Text(L10n.Global.name)
.font(.head1r)
.foregroundColor(.Material.Text.main)
.fixedSize(horizontal: true, vertical: false)
Text(L10n.Start.subtitle)
.font(.body2)
.foregroundColor(.Material.Text.sub)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.center)
}
// buttons
VStack(spacing: 16) {
Button {
withAnimation {
navigation.flow = .entering(.login)
}
} label: {
Text(L10n.Start.Btn.login)
}
.buttonStyle(SecondaryButtonStyle())
Button {
withAnimation {
navigation.flow = .entering(.registration)
}
} label: {
Text(L10n.Start.Btn.register)
}
.buttonStyle(PrimaryButtonStyle())
}
}
.padding(.horizontal, 32)
}
}
}

View file

@ -8,9 +8,9 @@ struct SharedTabBar: View {
.frame(height: 0.2) .frame(height: 0.2)
.foregroundColor(.Material.Shape.separator) .foregroundColor(.Material.Shape.separator)
HStack(spacing: 0) { HStack(spacing: 0) {
SharedTabBarButton(buttonFlow: .contacts) SharedTabBarButton(buttonFlow: .main(.contacts))
SharedTabBarButton(buttonFlow: .chats) SharedTabBarButton(buttonFlow: .main(.conversations))
SharedTabBarButton(buttonFlow: .settings) SharedTabBarButton(buttonFlow: .main(.settings))
} }
.background(Color.Material.Background.dark) .background(Color.Material.Background.dark)
} }
@ -19,38 +19,40 @@ struct SharedTabBar: View {
} }
private struct SharedTabBarButton: View { private struct SharedTabBarButton: View {
@EnvironmentObject var store: AppStore @EnvironmentObject var navigation: NavigationStore
let buttonFlow: AppFlow let buttonFlow: NavigationStore.Flow
var body: some View { var body: some View {
ZStack { ZStack {
VStack(spacing: 2) { VStack(spacing: 2) {
buttonImg buttonImg
.foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Elements.active : .Material.Elements.inactive) .foregroundColor(buttonFlow == navigation.flow ? .Material.Elements.active : .Material.Elements.inactive)
.font(.system(size: 24, weight: .light)) .font(.system(size: 24, weight: .light))
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
Text(buttonTitle) Text(buttonTitle)
.font(.sub1) .font(.sub1)
.foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Text.main : .Material.Elements.inactive) .foregroundColor(buttonFlow == navigation.flow ? .Material.Text.main : .Material.Elements.inactive)
} }
Rectangle() Rectangle()
.foregroundColor(.white.opacity(0.01)) .foregroundColor(.white.opacity(0.01))
.onTapGesture { .onTapGesture {
store.dispatch(.changeFlow(buttonFlow)) withAnimation {
navigation.flow = buttonFlow
}
} }
} }
} }
var buttonImg: Image { var buttonImg: Image {
switch buttonFlow { switch buttonFlow {
case .contacts: case .main(.contacts):
return Image(systemName: "person.2.fill") return Image(systemName: "person.2.fill")
case .chats: case .main(.conversations):
return Image(systemName: "bubble.left.fill") return Image(systemName: "bubble.left.fill")
case .settings: case .main(.settings):
return Image(systemName: "gearshape.fill") return Image(systemName: "gearshape.fill")
default: default:
@ -60,13 +62,13 @@ private struct SharedTabBarButton: View {
var buttonTitle: String { var buttonTitle: String {
switch buttonFlow { switch buttonFlow {
case .contacts: case .main(.contacts):
return "Contacts" return "Contacts"
case .chats: case .main(.conversations):
return "Chats" return "Chats"
case .settings: case .main(.settings):
return "Settings" return "Settings"
default: default:

View file

@ -0,0 +1,28 @@
import SwiftUI
struct StartScreen: View {
@EnvironmentObject var clientsStore: ClientsStore
@EnvironmentObject var navigation: NavigationStore
var body: some View {
ZStack {
Color.Material.Background.light
Image.logo
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
.ignoresSafeArea()
.onAppear {
clientsStore.startFetching()
}
.onChange(of: clientsStore.ready) { ready in
if ready {
let flow: NavigationStore.Flow = clientsStore.clients.isEmpty ? .entering(.welcome) : .main(.conversations)
withAnimation {
navigation.flow = flow
}
}
}
}
}

2
old/Generated/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -0,0 +1,7 @@
import Foundation
extension Bool {
var intValue: Int {
self ? 1 : 0
}
}

53
old/Helpers/Const.swift Normal file
View file

@ -0,0 +1,53 @@
import Foundation
import UIKit
enum Const {
// // Network
// #if DEBUG
// static let baseUrl = "staging.some.com/api"
// #else
// static let baseUrl = "prod.some.com/api"
// #endif
// static let requestTimeout = 15.0
// static let networkLogging = true
// App
static var appVersion: String {
let info = Bundle.main.infoDictionary
let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown"
let appBuild = info?[kCFBundleVersionKey as String] as? String ?? "Unknown"
return "v \(appVersion)(\(appBuild))"
}
static var appName: String {
Bundle.main.bundleIdentifier ?? "Conversations Classic iOS"
}
// Trusted servers
enum TrustedServers: String {
case narayana = "narayana.im"
case conversations = "conversations.im"
}
// Limit for video for sharing
static let videoDurationLimit = 60.0
// Upload/download file folder
static let fileFolder = "Downloads"
// Grid size for gallery preview (3 in a row)
static let galleryGridSize = UIScreen.main.bounds.width / 3
// Size for map preview for location messages
static let mapPreviewSize = UIScreen.main.bounds.width * 0.5
// Size for attachment preview
static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5
// Lenght in days for MAM request
static let mamRequestDaysLength = 30
// Limits for messages pagination
static let messagesPageMin = 20
static let messagesPageMax = 100
}

View file

@ -0,0 +1,16 @@
import MapKit
extension MKCoordinateRegion: Equatable {
public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool {
lhs.center.latitude == rhs.center.latitude &&
lhs.center.longitude == rhs.center.longitude &&
lhs.span.latitudeDelta == rhs.span.latitudeDelta &&
lhs.span.longitudeDelta == rhs.span.longitudeDelta
}
}
extension CLLocationCoordinate2D: Identifiable {
public var id: String {
"\(latitude)-\(longitude)"
}
}

View file

@ -0,0 +1,106 @@
import CoreLocation
import Foundation
import SwiftUI
extension String {
var firstLetter: String {
String(prefix(1)).uppercased()
}
var makeReply: String {
let allLines = components(separatedBy: .newlines)
let nonBlankLines = allLines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
var result = nonBlankLines.joined(separator: "\n")
result = "> \(result)"
return result
}
var isLocation: Bool {
hasPrefix("geo:")
}
var getLatLon: CLLocationCoordinate2D {
let geo = components(separatedBy: ":")[1]
let parts = geo.components(separatedBy: ",")
let lat = Double(parts[0]) ?? 0.0
let lon = Double(parts[1]) ?? 0.0
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
}
var isContact: Bool {
hasPrefix("contact:")
}
var getContactJid: String {
components(separatedBy: ":")[1]
}
}
extension String {
var attachmentType: MessageAttachmentType {
let ext = (self as NSString).pathExtension.lowercased()
switch ext {
case "mov", "mp4", "avi":
return .movie
case "jpg", "png", "gif":
return .image
case "mp3", "wav", "m4a":
return .audio
case "txt", "doc", "pdf":
return .file
default:
return .file
}
}
}
extension String {
var firstLetterColor: Color {
let firstLetter = self.firstLetter
switch firstLetter {
case "A", "M", "Y":
return Color.Rainbow.tortoiseLight500
case "B", "N", "Z":
return Color.Rainbow.orangeLight500
case "C", "O":
return Color.Rainbow.yellowLight500
case "D", "P":
return Color.Rainbow.greenLight500
case "E", "Q":
return Color.Rainbow.blueLight500
case "F", "R":
return Color.Rainbow.magentaLight500
case "G", "S":
return Color.Rainbow.tortoiseDark500
case "H", "T":
return Color.Rainbow.orangeDark500
case "I", "U":
return Color.Rainbow.yellowDark500
case "J", "V":
return Color.Rainbow.greenDark500
case "K", "W":
return Color.Rainbow.blueDark500
case "L", "X":
return Color.Rainbow.magentaDark500
default:
return Color.Rainbow.tortoiseLight500
}
}
}

View file

@ -0,0 +1,9 @@
import Foundation
extension TimeInterval {
var minAndSec: String {
let minutes = Int(self) / 60
let seconds = Int(self) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}

View file

@ -0,0 +1,10 @@
import UIKit
func openAppSettings() {
if
let appSettingsUrl = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(appSettingsUrl)
{
UIApplication.shared.open(appSettingsUrl, completionHandler: nil)
}
}

View file

@ -0,0 +1,13 @@
import UniformTypeIdentifiers
extension URL {
var mimeType: String {
let pathExtension = self.pathExtension
if let uti = UTType(filenameExtension: pathExtension) {
return uti.preferredMIMEType ?? "application/octet-stream"
} else {
return "application/octet-stream"
}
}
}

View file

@ -0,0 +1,32 @@
import Foundation
// Wrapper
@propertyWrapper
struct Storage<T> {
private let key: String
private let defaultValue: T
init(key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
// Read value from UserDefaults
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
// Set value to UserDefaults
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
// Storage
private let keyLocalizationSelected = "conversations.classic.user.defaults.localizationSelected"
enum UserSettings {
@Storage(key: keyLocalizationSelected, defaultValue: false)
static var localizationSelectedByUser: Bool
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE4",
"green" : "0xE4",
"red" : "0xE4"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Some files were not shown because too many files have changed in this diff Show more