mv-experiment #1
116
ConversationsClassic/AppData/Client/Client.swift
Normal file
116
ConversationsClassic/AppData/Client/Client.swift
Normal 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
|
||||
}
|
||||
}
|
152
ConversationsClassic/AppData/Client/RosterManager.swift
Normal file
152
ConversationsClassic/AppData/Client/RosterManager.swift
Normal 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) {}
|
||||
}
|
33
ConversationsClassic/AppData/Model/Credentials.swift
Normal file
33
ConversationsClassic/AppData/Model/Credentials.swift
Normal 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 }
|
||||
// }
|
||||
//
|
71
ConversationsClassic/AppData/Model/Roster.swift
Normal file
71
ConversationsClassic/AppData/Model/Roster.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}()
|
||||
}
|
55
ConversationsClassic/AppData/Services/Database.swift
Normal file
55
ConversationsClassic/AppData/Services/Database.swift
Normal 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
|
||||
}()
|
||||
}
|
42
ConversationsClassic/AppData/Services/Logger.swift
Normal file
42
ConversationsClassic/AppData/Services/Logger.swift
Normal 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
|
||||
}
|
37
ConversationsClassic/AppData/Services/NetworkMonitor.swift
Normal file
37
ConversationsClassic/AppData/Services/NetworkMonitor.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
41
ConversationsClassic/AppData/Stores/ClientsStore.swift
Normal file
41
ConversationsClassic/AppData/Stores/ClientsStore.swift
Normal 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
|
||||
}
|
||||
}
|
25
ConversationsClassic/AppData/Stores/NavigationStore.swift
Normal file
25
ConversationsClassic/AppData/Stores/NavigationStore.swift
Normal 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
|
||||
}
|
21
ConversationsClassic/AppData/Stores/RostersStore.swift
Normal file
21
ConversationsClassic/AppData/Stores/RostersStore.swift
Normal 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 {}
|
||||
// }
|
||||
}
|
||||
}
|
|
@ -1,31 +1,17 @@
|
|||
import Combine
|
||||
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
|
||||
@MainActor
|
||||
struct ConversationsClassic: App {
|
||||
private var clientsStore = ClientsStore()
|
||||
private var navigationStore = NavigationStore()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
BaseNavigationView()
|
||||
.environmentObject(store)
|
||||
AppRootView()
|
||||
.environmentObject(navigationStore)
|
||||
.environmentObject(clientsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,28 +36,28 @@ extension String {
|
|||
}
|
||||
}
|
||||
|
||||
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 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 {
|
||||
|
|
|
@ -5,21 +5,39 @@
|
|||
"Global.cancel" = "Cancel";
|
||||
"Global.save" = "Save";
|
||||
"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.Btn.login" = "Enter with JID";
|
||||
"Start.Btn.register" = "New Account";
|
||||
|
||||
// MARK: Login
|
||||
"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";
|
||||
"Login.error" = "Check internet connection, and make sure that JID and password are correct";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 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
|
||||
"Contacts.title" = "Contacts";
|
||||
|
|
36
ConversationsClassic/UIToolkit/View+Loader.swift
Normal file
36
ConversationsClassic/UIToolkit/View+Loader.swift
Normal 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)))
|
||||
}
|
||||
}
|
38
ConversationsClassic/View/AppRootView.swift
Normal file
38
ConversationsClassic/View/AppRootView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
172
ConversationsClassic/View/Contacts/ContactsScreen.swift
Normal file
172
ConversationsClassic/View/Contacts/ContactsScreen.swift
Normal 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
141
ConversationsClassic/View/Entering/LoginScreen.swift
Normal file
141
ConversationsClassic/View/Entering/LoginScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
22
ConversationsClassic/View/Entering/RegistrationScreen.swift
Normal file
22
ConversationsClassic/View/Entering/RegistrationScreen.swift
Normal 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()
|
||||
}
|
||||
}
|
56
ConversationsClassic/View/Entering/WelcomeScreen.swift
Normal file
56
ConversationsClassic/View/Entering/WelcomeScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,9 +8,9 @@ struct SharedTabBar: View {
|
|||
.frame(height: 0.2)
|
||||
.foregroundColor(.Material.Shape.separator)
|
||||
HStack(spacing: 0) {
|
||||
SharedTabBarButton(buttonFlow: .contacts)
|
||||
SharedTabBarButton(buttonFlow: .chats)
|
||||
SharedTabBarButton(buttonFlow: .settings)
|
||||
SharedTabBarButton(buttonFlow: .main(.contacts))
|
||||
SharedTabBarButton(buttonFlow: .main(.conversations))
|
||||
SharedTabBarButton(buttonFlow: .main(.settings))
|
||||
}
|
||||
.background(Color.Material.Background.dark)
|
||||
}
|
||||
|
@ -19,38 +19,40 @@ struct SharedTabBar: View {
|
|||
}
|
||||
|
||||
private struct SharedTabBarButton: View {
|
||||
@EnvironmentObject var store: AppStore
|
||||
@EnvironmentObject var navigation: NavigationStore
|
||||
|
||||
let buttonFlow: AppFlow
|
||||
let buttonFlow: NavigationStore.Flow
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 2) {
|
||||
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))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(buttonTitle)
|
||||
.font(.sub1)
|
||||
.foregroundColor(buttonFlow == store.state.currentFlow ? .Material.Text.main : .Material.Elements.inactive)
|
||||
.foregroundColor(buttonFlow == navigation.flow ? .Material.Text.main : .Material.Elements.inactive)
|
||||
}
|
||||
Rectangle()
|
||||
.foregroundColor(.white.opacity(0.01))
|
||||
.onTapGesture {
|
||||
store.dispatch(.changeFlow(buttonFlow))
|
||||
withAnimation {
|
||||
navigation.flow = buttonFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buttonImg: Image {
|
||||
switch buttonFlow {
|
||||
case .contacts:
|
||||
case .main(.contacts):
|
||||
return Image(systemName: "person.2.fill")
|
||||
|
||||
case .chats:
|
||||
case .main(.conversations):
|
||||
return Image(systemName: "bubble.left.fill")
|
||||
|
||||
case .settings:
|
||||
case .main(.settings):
|
||||
return Image(systemName: "gearshape.fill")
|
||||
|
||||
default:
|
||||
|
@ -60,13 +62,13 @@ private struct SharedTabBarButton: View {
|
|||
|
||||
var buttonTitle: String {
|
||||
switch buttonFlow {
|
||||
case .contacts:
|
||||
case .main(.contacts):
|
||||
return "Contacts"
|
||||
|
||||
case .chats:
|
||||
case .main(.conversations):
|
||||
return "Chats"
|
||||
|
||||
case .settings:
|
||||
case .main(.settings):
|
||||
return "Settings"
|
||||
|
||||
default:
|
||||
|
|
28
ConversationsClassic/View/StartScreen.swift
Normal file
28
ConversationsClassic/View/StartScreen.swift
Normal 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
2
old/Generated/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
7
old/Helpers/Bool+Extensions.swift
Normal file
7
old/Helpers/Bool+Extensions.swift
Normal file
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
extension Bool {
|
||||
var intValue: Int {
|
||||
self ? 1 : 0
|
||||
}
|
||||
}
|
53
old/Helpers/Const.swift
Normal file
53
old/Helpers/Const.swift
Normal 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
|
||||
}
|
16
old/Helpers/Map+Extensions.swift
Normal file
16
old/Helpers/Map+Extensions.swift
Normal 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)"
|
||||
}
|
||||
}
|
106
old/Helpers/String+Extensions.swift
Normal file
106
old/Helpers/String+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
9
old/Helpers/TimeInterval+Extensions.swift
Normal file
9
old/Helpers/TimeInterval+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
10
old/Helpers/UIApplication+Extensions.swift
Normal file
10
old/Helpers/UIApplication+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
13
old/Helpers/URL+Extensions.swift
Normal file
13
old/Helpers/URL+Extensions.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
32
old/Helpers/UserDefaultsWrapper.swift
Normal file
32
old/Helpers/UserDefaultsWrapper.swift
Normal 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
|
||||
}
|
6
old/Resources/Assets/Colors.xcassets/Contents.json
Normal file
6
old/Resources/Assets/Colors.xcassets/Contents.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue